diff --git a/README.md b/README.md index ba5d29e81..1bc56e074 100644 --- a/README.md +++ b/README.md @@ -865,8 +865,8 @@ the graph API. Its functions are also exported from the package entry point. reports stop outcomes, and reserves the writer slot for a live updater. A replacement bootstrap claims that reservation before running its CLI in the same process. Keep the coordination module available separately from the executable -being replaced, including during rollback. Live legacy writers require explicit -session quiescence before cutover. See the [runtime control contract](site/src/content/docs/reference/api.md#installer-runtime-control). +being replaced, including during rollback. Live old-version writers require explicit +confirmation that every other such server is stopped before cutover. See the [runtime control contract](site/src/content/docs/reference/api.md#installer-runtime-control). For ordinary checkout startup, `startRuntimeWatcher(root, { expectedVersion, cliPath, runtimePath?, timeoutMs? })` reuses or elects a shared daemon without diff --git a/__tests__/runtime-control.test.ts b/__tests__/runtime-control.test.ts index 5d2f4f625..1b18fa45f 100644 --- a/__tests__/runtime-control.test.ts +++ b/__tests__/runtime-control.test.ts @@ -264,7 +264,8 @@ describe('runtime identity and readiness', () => { }, ); - it('preserves a legacy daemon by default, then stops it only after explicit quiescence', async () => { + it.each(['oldServersStopped', 'legacyQuiescent'] as const)( + 'preserves a legacy daemon by default, then stops it only after %s is set', async (option) => { const actor = spawn(process.execPath, [path.resolve(__dirname, 'fixtures/runtime-control-daemon.cjs'), root, getDaemonSocketPath(root), 'legacy', path.resolve(__dirname, '../dist/mcp/writer-lock.js')], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); @@ -275,7 +276,7 @@ describe('runtime identity and readiness', () => { expect(actor.exitCode).toBeNull(); expect(fs.readFileSync(getDaemonPidPath(root), 'utf8')).toBe(before); const exited = once(actor, 'exit'); - expect(await stopRuntime(root, { legacyQuiescent: true })).toMatchObject({ pid: actor.pid, outcome: 'term' }); + expect(await stopRuntime(root, { [option]: true })).toMatchObject({ pid: actor.pid, outcome: 'term' }); await exited; const lease = reserveRuntimeWriter(root, process.pid); expect(lease).not.toBeNull(); diff --git a/site/src/content/docs/reference/api.md b/site/src/content/docs/reference/api.md index 726f70cf6..9929b4dff 100644 --- a/site/src/content/docs/reference/api.md +++ b/site/src/content/docs/reference/api.md @@ -121,10 +121,12 @@ standing down. A successful stop does not itself reserve the slot. Reserve before moving artifacts, and stand down if another writer has claimed it. By default, stop preserves a live daemon without writer coordination protocol -`1`. Set `{legacyQuiescent: true}` only after confirming all legacy MCP sessions -and direct or fallback writers for that project are stopped. Daemon death alone -does not establish that quiescence. A restored legacy daemon needs the same -explicit cutover before another promotion. +`1`. Set `{oldServersStopped: true}` only after confirming every other +old-version MCP session and every direct or fallback writer for that project is +stopped. The daemon serving the project is excluded because `stopRuntime` stops +it. Daemon death alone does not establish that. A restored old-version daemon needs the +same explicit confirmation before another promotion. `{legacyQuiescent: true}` +is the earlier spelling of the same option. Keep the reservation value unchanged. Its generation distinguishes successive reservations held by one PID. Claim it in the daemon bootstrap, then run the diff --git a/src/runtime-control.ts b/src/runtime-control.ts index 7c5b4a2a9..1cbc7f76b 100644 --- a/src/runtime-control.ts +++ b/src/runtime-control.ts @@ -32,18 +32,24 @@ export async function getRuntimeIdentity(projectRoot: string, options: { timeout return { pid: info.pid, version: info.version }; } -/** Refuse live uncoordinated writers unless the caller has explicitly quiesced legacy sessions. */ +/** + * Refuse live writers that predate the coordination protocol unless the caller + * confirms every other old-version server for the project has been stopped. The + * daemon this call stops is not part of that confirmation. + * `legacyQuiescent` is the earlier name for `oldServersStopped`. + */ export async function stopRuntime( projectRoot: string, - options: { legacyQuiescent?: boolean } = {}, + options: { oldServersStopped?: boolean; legacyQuiescent?: boolean } = {}, ): Promise { projectRoot = canonicalProjectRoot(projectRoot); return stopDaemonAt(projectRoot, { - preserveUnverified: true, requireWriterProtocol: !options.legacyQuiescent, + preserveUnverified: true, + requireWriterProtocol: !(options.oldServersStopped ?? options.legacyQuiescent), }); } -/** Claim a free or stale slot for a live updater, after terminal stop and legacy-session quiescence. */ +/** Claim a free or stale slot for a live updater, after a terminal stop and with every old-version server stopped. */ export function reserveRuntimeWriter(projectRoot: string, holderPid: number): WriterReservation | null { projectRoot = canonicalProjectRoot(projectRoot); const result = reserveWriterLock(projectRoot, holderPid);