From d0b2475c6d05b5af23dc01739c3d2c3ec8502691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 2 Oct 2026 23:50:08 +0200 Subject: [PATCH 1/4] fix: centralize confirmed daemon retirement under the startup lock --- CONTRIBUTING.md | 5 + scripts/clean-daemon.ts | 100 +++------ .../daemon-registration-owner.test.ts | 134 +++++++++++- src/daemon-registration-owner.ts | 200 ++++++++++++++++-- src/daemon-registration.ts | 3 +- test/integration/smoke-daemon-clean.test.ts | 39 +++- 6 files changed, 400 insertions(+), 81 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 128431377e..09f7401cf5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,11 @@ pnpm test-app:typecheck `pnpm build` compiles the TypeScript CLI and library. If a running development daemon must pick up that build, use `pnpm rebuild:cli`; it builds and then stops the worktree-scoped daemon. +`pnpm clean:daemon` retains state when it cannot confirm the recorded daemon exited. Restore +process inspection or stop the verified owner before retrying. Its `--prune-dev` option retires +confirmed abandoned registrations older than 14 days; it keeps session artifacts and state +directories for inspection. + Build only the Apple runner target you changed: ```bash diff --git a/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index 659afeb6ad..efd775881a 100644 --- a/scripts/clean-daemon.ts +++ b/scripts/clean-daemon.ts @@ -3,81 +3,60 @@ import os from 'node:os'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { resolveDaemonPaths } from '../src/daemon-resolution.ts'; -import { isAgentDeviceDaemonProcess, stopDaemonProcess } from '../src/daemon-process.ts'; +import { readRegisteredDaemonIdentity } from '../src/daemon-registration.ts'; +import { + stopAndRetireDaemon, + recoverAbandonedDaemonRegistration, +} from '../src/daemon-registration-owner.ts'; const DAEMON_TERM_TIMEOUT_MS = 15_000; const DAEMON_KILL_TIMEOUT_MS = 2_000; const PRUNE_DEV_FLAG = '--prune-dev'; const PRUNE_DEV_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; -type DaemonInfo = { - pid?: number; - processStartTime?: string; -}; - const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR); -const info = readDaemonInfo(paths.infoPath); -const daemonPid = readPositivePid(info?.pid); - -if (daemonPid !== null) { - const termination = await stopDaemonProcess( - { pid: daemonPid, startTime: info?.processStartTime ?? null }, +const retirement = await stopAndRetireDaemon({ + paths, + observed: readRegisteredDaemonIdentity(paths.infoPath), + mode: 'graceful', + termTimeoutMs: DAEMON_TERM_TIMEOUT_MS, + killTimeoutMs: DAEMON_KILL_TIMEOUT_MS, +}); +if (retirement.status === 'retained') { + throw new AppError( + 'COMMAND_FAILED', + 'Daemon cleanup retained state because retirement could not be confirmed.', { - mode: 'graceful', - termTimeoutMs: DAEMON_TERM_TIMEOUT_MS, - killTimeoutMs: DAEMON_KILL_TIMEOUT_MS, + reason: 'daemon_retirement_unconfirmed', + retirement, + hint: retirement.error?.hint, }, ); - if (termination.status !== 'exited') { - throw new AppError( - 'COMMAND_FAILED', - 'Daemon cleanup retained state because exit could not be confirmed.', - { - reason: 'daemon_exit_unconfirmed', - termination, - }, - ); - } +} +if (retirement.status === 'retired') { const { cleanupRunnerLeasesForOwner } = await import('@agent-device/platform-apple/runner/operations'); - await cleanupRunnerLeasesForOwner({ pid: daemonPid, startTime: info?.processStartTime }); + await cleanupRunnerLeasesForOwner(retirement.termination.identity); } -removeIfPresent(paths.infoPath); -removeIfPresent(paths.lockPath); - if (process.argv.includes(PRUNE_DEV_FLAG)) { - pruneStaleDevStateDirs(); -} - -function readDaemonInfo(infoPath: string): DaemonInfo | null { - try { - return JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo; - } catch { - return null; - } -} - -function removeIfPresent(filePath: string): void { - try { - fs.unlinkSync(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } + await pruneStaleDevStateDirs(); } -// Removes worktree-scoped state dirs under ~/.agent-device/dev/ that no live daemon -// owns and that have been idle past the retention threshold. Never touches the -// global ~/.agent-device root contents. -function pruneStaleDevStateDirs(): void { +async function pruneStaleDevStateDirs(): Promise { const devRoot = path.join(os.homedir(), '.agent-device', 'dev'); const cutoffMs = Date.now() - PRUNE_DEV_MAX_AGE_MS; for (const dirPath of listDevStateDirs(devRoot)) { - if (hasLiveDaemon(dirPath) || newestMtimeMs(dirPath) > cutoffMs) continue; - fs.rmSync(dirPath, { recursive: true, force: true }); - process.stdout.write(`Removed stale daemon state dir: ${dirPath}\n`); + if (newestMtimeMs(dirPath) > cutoffMs) continue; + const paths = resolveDaemonPaths(dirPath); + const result = await recoverAbandonedDaemonRegistration({ + paths, + observed: readRegisteredDaemonIdentity(paths.infoPath), + }); + if (result.status === 'retired') + process.stdout.write( + `Retired stale daemon registration: ${dirPath} (session artifacts retained)\n`, + ); } } @@ -93,17 +72,6 @@ function listDevStateDirs(devRoot: string): string[] { .map((entry) => path.join(devRoot, entry.name)); } -function hasLiveDaemon(stateDir: string): boolean { - const dirInfo = readDaemonInfo(path.join(stateDir, 'daemon.json')); - const pid = readPositivePid(dirInfo?.pid); - return pid !== null && isAgentDeviceDaemonProcess(pid, dirInfo?.processStartTime); -} - -function readPositivePid(pid: number | undefined): number | null { - if (typeof pid !== 'number') return null; - return Number.isInteger(pid) && pid > 0 ? pid : null; -} - function newestMtimeMs(dirPath: string): number { let newest = statMtimeMs(dirPath); let children: fs.Dirent[]; diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index ead76afb78..edb42abced 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, test, vi } from 'vitest'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; -import { tryAcquireDaemonRegistration } from '../daemon-registration-owner.ts'; +import { + tryAcquireDaemonRegistration, + stopAndRetireDaemon, + recoverAbandonedDaemonRegistration, +} from '../daemon-registration-owner.ts'; import { resolveDaemonPaths, type DaemonPaths } from '../daemon-resolution.ts'; import { readRegisteredDaemonOwnership } from '../daemon-registration.ts'; import { readDaemonShutdownReport } from '../daemon-shutdown-report.ts'; @@ -173,3 +177,131 @@ test('unlink disappearance is settled, other failures remain primary even when r assert.equal(fs.existsSync(second.paths.infoPath), true); assert.match(fs.readFileSync(second.paths.logPath, 'utf8'), /daemon_registration_release_failed/); }); + +const deadIdentity = { pid: 999_999_999, startTime: 'dead-start' }; +for (const scenario of [ + { + name: 'missing-start', + observed: { pid: process.pid, startTime: null }, + published: ownIdentity, + retired: false, + }, + { name: 'live-unverified', observed: ownIdentity, published: ownIdentity, retired: false }, + { name: 'no-observation', observed: null, published: deadIdentity, retired: false }, + { name: 'replaced', observed: deadIdentity, published: ownIdentity, retired: false }, + { name: 'dead', observed: deadIdentity, published: deadIdentity, retired: true }, +] as const) { + test(`retirement respects the captured lifetime (${scenario.name})`, async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-retirement-')); + replaceInfo(paths, scenario.published.pid, scenario.published.startTime); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const result = await stopAndRetireDaemon({ + paths, + observed: scenario.observed, + mode: 'graceful', + lockTimeoutMs: 0, + }); + if (scenario.retired) { + assert.equal(result.status, 'retired'); + assert.equal(result.removedInfo, true); + assert.equal(fs.existsSync(paths.infoPath), false); + } else { + assert.equal(result.status, 'retained'); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); + } + }); +} + +test('abandoned recovery never signals a live owner and absence is inspected under the lock', async () => { + const { paths, owner } = await acquire(); + owner.publish(fields); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const result = await recoverAbandonedDaemonRegistration({ paths, observed: ownIdentity }); + assert.equal(result.status, 'retained'); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); + await owner.finish(); + assert.deepEqual(await recoverAbandonedDaemonRegistration({ paths, observed: null }), { + status: 'absent', + removedInfo: false, + }); +}); + +test('retirement preserves a winner holding the startup lock instead of stopping or unlinking it', async () => { + const { paths, owner } = await acquire(); + owner.publish({ ...fields, token: 'winning-token' }); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const result = await stopAndRetireDaemon({ + paths, + observed: { pid: 999_999_999, startTime: 'dead-start' }, + mode: 'force', + lockTimeoutMs: 0, + }); + assert.equal(result.status, 'retained'); + if (result.status !== 'retained') throw new Error('retirement unexpectedly completed'); + assert.equal(result.reason, 'lock-busy'); + assert.equal(result.error?.details?.reason, 'process_lock_timeout'); + assert.match(result.error?.hint ?? '', /Restore process inspection/); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); + assert.equal((await tryAcquireDaemonRegistration(paths)).status, 'busy'); + await owner.finish(); +}); + +test('metadata failure remains primary and normalized through a secondary release failure', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-retirement-failures-')); + const observed = { pid: 999_999_999, startTime: 'dead-start' }; + replaceInfo(paths, observed.pid, observed.startTime); + const primary = Object.assign(new Error('metadata failure'), { code: 'EIO' }); + const originalUnlink = fs.unlinkSync; + vi.spyOn(fs, 'unlinkSync').mockImplementation((target) => { + if (target === paths.infoPath) throw primary; + return originalUnlink(target); + }); + const originalRmdir = fs.rmdirSync; + vi.spyOn(fs, 'rmdirSync').mockImplementation((target, options) => { + if (target === paths.lockPath) throw Object.assign(new Error('busy'), { code: 'EBUSY' }); + return originalRmdir(target, options); + }); + const result = await stopAndRetireDaemon({ paths, observed, mode: 'force' }); + assert.equal(result.status, 'retained'); + if (result.status !== 'retained') throw new Error('retirement unexpectedly completed'); + assert.equal(result.error?.message, 'metadata failure'); + assert.equal(result.error?.code, 'UNKNOWN'); + assert.equal(result.error?.cause?.code, 'EIO'); + assert.equal(fs.existsSync(paths.infoPath), true); +}); + +test('release failure reports partial retirement rather than success', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-retirement-partial-')); + const observed = { pid: 999_999_999, startTime: 'dead-start' }; + replaceInfo(paths, observed.pid, observed.startTime); + const original = fs.rmdirSync; + vi.spyOn(fs, 'rmdirSync').mockImplementation((target, options) => { + if (target === paths.lockPath) throw Object.assign(new Error('busy'), { code: 'EBUSY' }); + return original(target, options); + }); + const result = await stopAndRetireDaemon({ paths, observed, mode: 'force' }); + assert.equal(result.status, 'retained'); + assert.equal(result.removedInfo, true); + assert.equal(fs.existsSync(paths.infoPath), false); + assert.equal(fs.existsSync(paths.lockPath), true); +}); + +test.skipIf(process.getuid?.() === 0)( + 'an acquisition I/O failure retains metadata without reporting contention', + async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-retirement-permission-')); + replaceInfo(paths, deadIdentity.pid, deadIdentity.startTime); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + fs.chmodSync(paths.baseDir, 0o500); + try { + const result = await recoverAbandonedDaemonRegistration({ paths, observed: deadIdentity }); + assert.equal(result.status, 'retained'); + if (result.status !== 'retained') throw new Error('retirement unexpectedly completed'); + assert.equal(result.reason, 'retirement-unconfirmed'); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); + assert.equal(fs.existsSync(paths.lockPath), false); + } finally { + fs.chmodSync(paths.baseDir, 0o700); + } + }, +); diff --git a/src/daemon-registration-owner.ts b/src/daemon-registration-owner.ts index 92ec9f9ae0..0092f087af 100644 --- a/src/daemon-registration-owner.ts +++ b/src/daemon-registration-owner.ts @@ -1,9 +1,15 @@ import fs from 'node:fs'; -import { normalizeError } from '@agent-device/kernel/errors'; +import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; +import { + stopDaemonProcess, + waitForDaemonExit, + type DaemonTerminationResult, +} from './daemon-process.ts'; import { readCurrentOwnerIdentity, type OwnerIdentity } from '@agent-device/host-kit/process'; import { publishFileSync, tryAcquireProcessLock, + acquireProcessLockAcquisition, type ProcessLockAttempt, type ProcessLockAcquisition, } from '@agent-device/host-kit/file'; @@ -102,7 +108,7 @@ export async function tryAcquireDaemonRegistration( function removeRegistrationUnderLock( infoPath: string, - identity: OwnerIdentity, + identity: OwnerIdentity | null, acquisition: ProcessLockAcquisition, ): DaemonRegistrationRemoval { acquisition.assertHeld(); @@ -125,16 +131,7 @@ async function releaseRegistrationAfterFailure( try { await acquisition.release(); } catch (releaseError) { - await withDiagnosticsScope( - { command: 'daemon', session: 'daemon', logPath, debug: true }, - () => { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_registration_release_failed', - data: { error: normalizeError(releaseError) }, - }); - }, - ); + await recordRegistrationWarning(logPath, 'daemon_registration_release_failed', releaseError); } throw error; } @@ -163,3 +160,182 @@ function truncateDaemonLog(logPath: string): void { fs.closeSync(descriptor); } } + +export type DaemonRetirementInput = Readonly<{ + paths: DaemonPaths; + observed: OwnerIdentity | null; + termTimeoutMs?: number; + killTimeoutMs?: number; + lockTimeoutMs?: number; +}>; + +type ConfirmedDaemonTermination = Extract; +export type DaemonRetirementResult = + | Readonly<{ status: 'retired'; termination: ConfirmedDaemonTermination; removedInfo: boolean }> + | Readonly<{ status: 'absent'; removedInfo: false }> + | Readonly<{ + status: 'retained'; + termination?: DaemonTerminationResult; + removedInfo: boolean; + reason: + | 'ownership-unproven' + | 'exit-unconfirmed' + | 'stop-failed' + | 'lock-busy' + | 'registration-replaced' + | 'metadata-unreadable' + | 'retirement-unconfirmed'; + error?: NormalizedError; + }>; + +/** Stops only the captured daemon lifetime, then retires its registration under the startup lock. */ +export async function stopAndRetireDaemon( + input: DaemonRetirementInput & Readonly<{ mode: 'graceful' | 'force' }>, +): Promise { + return await retireObservedDaemon(input, (identity) => + stopDaemonProcess(identity, { + mode: input.mode, + termTimeoutMs: input.termTimeoutMs ?? 3_000, + killTimeoutMs: input.killTimeoutMs ?? 1_000, + }), + ); +} + +/** Recovers a confirmed abandoned registration without signaling a live process. */ +export async function recoverAbandonedDaemonRegistration( + input: DaemonRetirementInput, +): Promise { + return await retireObservedDaemon(input, async (identity) => { + if (!identity.startTime?.trim()) return { status: 'retained', reason: 'missing-start-time' }; + const confirmed = { pid: identity.pid, startTime: identity.startTime }; + return (await waitForDaemonExit(confirmed, { timeoutMs: 0 })).exited + ? { status: 'exited', identity: confirmed, mode: 'already-exited' } + : { status: 'retained', reason: 'identity-unverified' }; + }); +} + +async function retireObservedDaemon( + input: DaemonRetirementInput, + terminate: (identity: OwnerIdentity) => Promise, +): Promise { + const paths = { ...input.paths }; + const observed = input.observed && { ...input.observed }; + let termination: ConfirmedDaemonTermination | undefined; + if (observed) { + try { + const result = await terminate(observed); + if (result.status !== 'exited') + return { + status: 'retained', + reason: 'exit-unconfirmed', + termination: result, + removedInfo: false, + }; + termination = result; + } catch (error) { + return { + status: 'retained', + reason: 'stop-failed', + removedInfo: false, + error: normalizeError(error), + }; + } + } + return await retireDaemonRegistration({ ...input, paths }, termination); +} + +async function retireDaemonRegistration( + input: DaemonRetirementInput, + termination: ConfirmedDaemonTermination | undefined, +): Promise { + const paths = input.paths; + let acquisition: ProcessLockAcquisition; + try { + acquisition = await acquireProcessLockAcquisition({ + lockDirPath: paths.lockPath, + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, + description: 'daemon registration retirement', + timeoutMs: input.lockTimeoutMs ?? 1_000, + }); + } catch (error) { + const failure = normalizeError(error); + return { + status: 'retained', + reason: + failure.details?.reason === 'process_lock_timeout' ? 'lock-busy' : 'retirement-unconfirmed', + termination, + removedInfo: false, + error: failure, + }; + } + let result: DaemonRetirementResult; + try { + const removal = removeRegistrationUnderLock( + paths.infoPath, + termination?.identity ?? null, + acquisition, + ); + result = retirementAfterRemoval(removal, termination); + } catch (error) { + result = { + status: 'retained', + reason: 'retirement-unconfirmed', + termination, + removedInfo: false, + error: normalizeError(error), + }; + } + return await releaseAfterRetirement(acquisition, result, paths.logPath); +} + +async function releaseAfterRetirement( + acquisition: ProcessLockAcquisition, + result: DaemonRetirementResult, + logPath: string, +): Promise { + try { + await acquisition.release(); + } catch (error) { + const primary = result.status === 'retained' ? result.error : undefined; + if (primary) + await recordRegistrationWarning(logPath, 'daemon_retirement_release_failed', error); + result = { + ...result, + status: 'retained', + reason: 'retirement-unconfirmed', + error: primary ?? normalizeError(error), + }; + } + return result; +} + +function retirementAfterRemoval( + removal: DaemonRegistrationRemoval, + termination: ConfirmedDaemonTermination | undefined, +): DaemonRetirementResult { + if (termination && (removal.state === 'removed' || removal.state === 'absent')) { + return { status: 'retired', termination, removedInfo: removal.state === 'removed' }; + } + if (removal.state === 'absent') return { status: 'absent', removedInfo: false }; + return { + status: 'retained', + termination, + removedInfo: false, + reason: + removal.state === 'replaced' + ? 'registration-replaced' + : removal.state === 'unreadable' + ? 'metadata-unreadable' + : 'ownership-unproven', + }; +} + +async function recordRegistrationWarning( + logPath: string, + phase: string, + error: unknown, +): Promise { + await withDiagnosticsScope({ command: 'daemon', session: 'daemon', logPath, debug: true }, () => { + emitDiagnostic({ level: 'warn', phase, data: { error: normalizeError(error) } }); + }); +} diff --git a/src/daemon-registration.ts b/src/daemon-registration.ts index 528fa3f627..ad598afc3c 100644 --- a/src/daemon-registration.ts +++ b/src/daemon-registration.ts @@ -80,10 +80,11 @@ export type RegisteredDaemonOwnership = export function readRegisteredDaemonOwnership( infoPath: string, - owner: OwnerIdentity, + owner: OwnerIdentity | null, ): RegisteredDaemonOwnership { const record = readRegistration(infoPath); if (record.status !== 'registered') return { state: record.status }; + if (!owner) return { state: 'unproven' }; if (ownerIdentityDiffers(record.identity, owner)) { return { state: 'replaced', identity: record.identity }; } diff --git a/test/integration/smoke-daemon-clean.test.ts b/test/integration/smoke-daemon-clean.test.ts index 4d36c81823..dc5c89192a 100644 --- a/test/integration/smoke-daemon-clean.test.ts +++ b/test/integration/smoke-daemon-clean.test.ts @@ -7,6 +7,8 @@ import path from 'node:path'; import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts'; import { runCmdSync } from '@agent-device/host-kit/command'; import { isProcessAlive, readProcessStartTime } from '@agent-device/host-kit/process'; +import { stopAndRetireDaemon } from '../../src/daemon-registration-owner.ts'; +import { resolveDaemonPaths } from '../../src/daemon-resolution.ts'; import { assertNoDaemonLeaks } from './support/daemon-leak-oracle.ts'; import { runCliJson } from './test-helpers.ts'; @@ -36,8 +38,9 @@ test('clean daemon retains metadata when a live recorded process is not a verifi assert.notEqual(cleanup.exitCode, 0, 'unconfirmed exit cannot complete cleanup'); assert.match( cleanup.stderr, - /Daemon cleanup retained state because exit could not be confirmed/, + /Daemon cleanup retained state because retirement could not be confirmed/, ); + assert.match(cleanup.stderr, /exit-unconfirmed/); assert.equal(fs.readFileSync(path.join(stateDir, 'daemon.json'), 'utf8'), contents); assert.equal(fs.readFileSync(path.join(stateDir, 'daemon.lock'), 'utf8'), contents); assert.equal(isProcessAlive(process.pid), true); @@ -81,6 +84,40 @@ test('clean daemon script stops a live daemon before removing metadata', async ( } }); +test('forced retirement waits for a real daemon and reclaims its abandoned registration claim', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-forced-retirement-')); + let info: DaemonInfo | undefined; + try { + const cli = runCliJson(['session', 'list', '--json', '--state-dir', stateDir]); + assert.equal(cli.status, 0, `${cli.stderr}\n${cli.stdout}`); + info = readDaemonInfo(stateDir); + const paths = resolveDaemonPaths(stateDir); + assert.equal(fs.existsSync(paths.lockPath), true); + const result = await stopAndRetireDaemon({ + paths, + observed: { pid: info.pid, startTime: info.processStartTime ?? null }, + mode: 'force', + }); + assert.equal(result.status, 'retired', JSON.stringify(result)); + if (result.status !== 'retired') assert.fail('retirement not confirmed'); + assert.equal(result.termination.mode, 'forced'); + assert.equal(result.removedInfo, true); + assert.equal(isProcessAlive(info.pid), false); + assert.equal(fs.existsSync(paths.infoPath), false); + assert.equal(fs.existsSync(paths.lockPath), false); + } finally { + if (info) { + const termination = await stopDaemonProcess( + { pid: info.pid, startTime: info.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_500 }, + ); + assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + function readDaemonInfo(stateDir: string): DaemonInfo { return JSON.parse(fs.readFileSync(path.join(stateDir, 'daemon.json'), 'utf8')) as DaemonInfo; } From 686c345f0cb048bd2417a3d0e4fae077074043a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 00:55:15 +0200 Subject: [PATCH 2/4] docs: describe dev state directory pruning cutoff --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09f7401cf5..43dddfb0c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,9 +38,9 @@ pnpm test-app:typecheck that build, use `pnpm rebuild:cli`; it builds and then stops the worktree-scoped daemon. `pnpm clean:daemon` retains state when it cannot confirm the recorded daemon exited. Restore -process inspection or stop the verified owner before retrying. Its `--prune-dev` option retires -confirmed abandoned registrations older than 14 days; it keeps session artifacts and state -directories for inspection. +process inspection or stop the verified owner before retrying. Its `--prune-dev` option considers +dev state directories whose newest observed modification is at least 14 days old. It retires +confirmed abandoned registrations and keeps session artifacts and state directories for inspection. Build only the Apple runner target you changed: From 32b9b1df5cdaa032c40311f69942acf4c852a08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 01:00:09 +0200 Subject: [PATCH 3/4] test: keep force-retirement cleanup best effort --- test/integration/smoke-daemon-clean.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/integration/smoke-daemon-clean.test.ts b/test/integration/smoke-daemon-clean.test.ts index dc5c89192a..0528492132 100644 --- a/test/integration/smoke-daemon-clean.test.ts +++ b/test/integration/smoke-daemon-clean.test.ts @@ -107,14 +107,7 @@ test('forced retirement waits for a real daemon and reclaims its abandoned regis assert.equal(fs.existsSync(paths.infoPath), false); assert.equal(fs.existsSync(paths.lockPath), false); } finally { - if (info) { - const termination = await stopDaemonProcess( - { pid: info.pid, startTime: info.processStartTime ?? null }, - { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_500 }, - ); - assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); - } - fs.rmSync(stateDir, { recursive: true, force: true }); + await cleanupDaemonTestState(stateDir, info); } }); From b45777ad42fe64df782e184b9e84ad2fe711a763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 01:03:33 +0200 Subject: [PATCH 4/4] test: normalize an unobserved force-retirement daemon --- test/integration/smoke-daemon-clean.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/smoke-daemon-clean.test.ts b/test/integration/smoke-daemon-clean.test.ts index 0528492132..a8f0dde5ab 100644 --- a/test/integration/smoke-daemon-clean.test.ts +++ b/test/integration/smoke-daemon-clean.test.ts @@ -80,7 +80,7 @@ test('clean daemon script stops a live daemon before removing metadata', async ( // leave only classified artifacts in its state dir. await assertNoDaemonLeaks({ stateDir, daemonPids: [info.pid], phase: 'after-shutdown' }); } finally { - await cleanupDaemonTestState(stateDir, info); + await cleanupDaemonTestState(stateDir, info ?? null); } }); @@ -107,7 +107,7 @@ test('forced retirement waits for a real daemon and reclaims its abandoned regis assert.equal(fs.existsSync(paths.infoPath), false); assert.equal(fs.existsSync(paths.lockPath), false); } finally { - await cleanupDaemonTestState(stateDir, info); + await cleanupDaemonTestState(stateDir, info ?? null); } });