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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions __tests__/runtime-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] });
Expand All @@ -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();
Expand Down
10 changes: 6 additions & 4 deletions site/src/content/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions src/runtime-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StopResult> {
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);
Expand Down