diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c290f9c..71f3556 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,7 +120,7 @@ jobs: RELEASE_REF: ${{ needs.resolve.outputs.ref }} run: | if [ "$RELEASE_CHANNEL" = nightly ]; then - gh release create "v$RELEASE_VERSION" "release/t3poll-$RELEASE_VERSION.tgz" --target "$RELEASE_REF" --title "t3poll $RELEASE_VERSION" --notes "Nightly npm release from $RELEASE_REF. Running workers retain their loaded code; automatic worker handoff is not yet implemented." --prerelease --latest=false + gh release create "v$RELEASE_VERSION" "release/t3poll-$RELEASE_VERSION.tgz" --target "$RELEASE_REF" --title "t3poll $RELEASE_VERSION" --notes "Nightly npm release from $RELEASE_REF. Newer runtimes support graceful worker handoff with persisted watches." --prerelease --latest=false else gh release create "v$RELEASE_VERSION" "release/t3poll-$RELEASE_VERSION.tgz" --target "$RELEASE_REF" --title "t3poll $RELEASE_VERSION" --generate-notes --latest fi diff --git a/.plans/npm-distribution.md b/.plans/npm-distribution.md index c609e6d..e76189d 100644 --- a/.plans/npm-distribution.md +++ b/.plans/npm-distribution.md @@ -1,6 +1,6 @@ # npm distribution and MCP launch -Status: release infrastructure implemented after installer and platform verification. See [release operations](../docs/releases.md) for nightly publication, trusted publishing, and stable promotion. Worker handoff remains separate work. +Status: release infrastructure implemented after installer and platform verification. See [release operations](../docs/releases.md) for nightly publication, trusted publishing, and stable promotion. Worker handoff is implemented; see [update behavior](../docs/updates.md). ## Intended experience @@ -24,6 +24,6 @@ Initial publication needs an npm account and control of the chosen name. GitHub ## Done when -A clean installation exposes the same three MCP tools and automatic setup works without a checkout. Documentation explains startup-time updates and the remaining worker limitation without claiming hot updates. +A clean installation exposes the same three MCP tools and automatic setup works without a checkout. Documentation explains startup-time updates, graceful worker handoff, and compatibility limits. References: [npm execution and cache behavior](https://docs.npmjs.com/cli/npm-exec/), [publishing public scoped packages](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/). diff --git a/.plans/setup-and-release.md b/.plans/setup-and-release.md index 24f8416..5c3f179 100644 --- a/.plans/setup-and-release.md +++ b/.plans/setup-and-release.md @@ -1,6 +1,6 @@ # Setup and release work order -Status: Windows x64 support is implemented and tested on Gideon; see [verification](../docs/compatibility.md#windows-verification). The setup installer is implemented; see [installer usage](../docs/installer.md). The npm release workflow is implemented; see [release operations](../docs/releases.md). Automatic update handoff remains planned. +Status: Windows x64 support is implemented and tested on Gideon; see [verification](../docs/compatibility.md#windows-verification). The setup installer is implemented; see [installer usage](../docs/installer.md). The npm release workflow is implemented; see [release operations](../docs/releases.md). Worker update handoff is implemented; see [update behavior](../docs/updates.md). ## Order of work diff --git a/.plans/worker-updates.md b/.plans/worker-updates.md index 78f483a..67292ce 100644 --- a/.plans/worker-updates.md +++ b/.plans/worker-updates.md @@ -1,21 +1,7 @@ # Update code while retaining watches -Status: discussed, not implemented. No handoff design has been selected. +Status: implemented. See [update behavior](../docs/updates.md). -## Problem +New MCP sessions or CLI calls load their selected npm channel. A successfully loaded newer worker advertises its runtime, waits for the current worker to drain, and acquires the existing lease. Persisted watch state is retained. The remembered runtime prevents older clients from restarting an older worker after idle. No permanent supervisor or background registry poll was added. -A running worker retains its loaded code after a rebuild or MCP reconnect. Current instructions recommend updating between watches. Stopping and re-registering a watch creates a new baseline and can miss activity during the gap. - -## Desired behavior - -When new code takes over, preserve watch IDs, expiration, snapshots, pending changes, and frozen dispatch commands. An upgrade must not replay old feedback, submit a pending message twice, or run two workers for the same state directory. - -## Suggested starting point - -Inspect `src/worker.ts` and `src/store.ts`. They already coordinate a single worker using a lease and persist watch state. Consider recording a worker version and handing over after the current operation finishes. Keep retries on the exact persisted command/message IDs. - -Do not add a permanent supervisor just for upgrades. Decide how a new MCP process requests a handoff, how an old MCP process avoids replacing a newer worker, and how incompatible stored-state versions are handled before choosing a mechanism. - -## Done when - -A process test upgrades a worker with an active watch and pending delivery, preserves the baseline, and verifies only one worker owns delivery. Include failed startup and an in-flight dispatch. Update README guidance once this works. npm startup-time updates alone do not satisfy this plan. +Process tests exercise accepted and ambiguous in-flight deliveries, baseline and pending-state retention, failed candidate startup, older clients after idle, and MCP startup recovery. diff --git a/README.md b/README.md index dbe6887..377c2a2 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ Three MCP tools: `watch`, `list`, and `stop`. `watch` automatically starts a bac ## Setup wizard -The first releases are available on the nightly channel. With T3 running: +With T3 running: ```sh -npx t3poll@nightly setup +npx t3poll@latest setup ``` -The wizard discovers T3, configures Codex with T3-only tools, preserves existing launch arguments, backs up changed files, and verifies the connection. Add `--dry-run` to preview. Nightlies are for testing; automatic worker handoff is not implemented yet. A stable release will follow upgrade testing. +The wizard discovers T3, configures Codex with T3-only tools, preserves existing launch arguments, backs up changed files, and verifies the connection. Add `--dry-run` to preview. Use `npx t3poll@nightly setup` to follow automatic nightly releases. Stable releases are promoted manually. For development from a checkout: @@ -63,19 +63,13 @@ See [monitoring behavior](docs/behavior.md) for notification examples, polling o ## Updates -npm installations resolve their selected release channel when MCP starts. Active workers keep their loaded code; automatic worker handoff is not implemented. Let active watches finish before restarting MCP to update. +npm installations resolve their selected release channel when a new Codex session starts. A newer runtime takes over the shared worker after its current operation finishes. Watches keep their IDs, expiration, baseline, queued changes, and pending delivery IDs. Older sessions keep using the newer worker. -For local checkout installations, update with: +To trigger an update immediately, run `npx --yes --prefer-online t3poll@nightly list`, or use `@latest` for stable. This updates the worker; existing MCP sessions keep their loaded code until reconnected. MCP startup resumes saved watches and checks worker health every 30 seconds. There is no background registry polling. -```sh -git pull --ff-only -npm ci -npm run build -``` - -Reconnect the t3poll MCP server or use a new provider session to load the new code. The saved MCP entry still points to the same file. A running MCP process or worker keeps its old code until it exits; rebuilding alone does not upgrade it. +Local rebuilds with an unchanged version require waiting for the worker to exit. -If you need to update sooner, record your watch destinations, stop those watches, wait for the worker to exit, then update and register them again. Re-registering starts a fresh baseline, so changes during the gap will not generate notifications. State lives outside the checkout in `~/.local/share/t3poll` by default. +See [update behavior](docs/updates.md) for failure recovery, channel switching, and compatibility. State lives outside the package in `~/.local/share/t3poll` by default. Automatically created credentials last 30 days. t3poll replaces them on use during their last day or after expiration, including from the background worker. Explicitly supplied token files remain your responsibility. diff --git a/docs/installer.md b/docs/installer.md index 3b5f226..6c35ef2 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -8,13 +8,13 @@ npm run build node dist/cli.js setup --runtime-path ./dist/cli.js ``` -`--runtime-path` uses this checkout's built CLI. Keep the checkout in place. To install the published nightly without a checkout, use: +`--runtime-path` uses this checkout's built CLI. Keep the checkout in place. To install the published package without a checkout, use: ```sh -npx t3poll@nightly setup +npx t3poll@latest setup ``` -The first releases are nightly-only. Stable publication will follow worker upgrade testing. There is no release-channel question. A stable package configures `latest`; a package whose version contains the nightly prerelease identifier configures `nightly`. npm does not pass its original tag to the program, so setup derives the channel from the running package's version. Unknown prerelease versions are rejected. An explicit local runtime opts out of npm updates. +Use `@nightly` instead to follow automatic nightly releases. There is no release-channel question. A stable package configures `latest`; a package whose version contains the nightly prerelease identifier configures `nightly`. npm does not pass its original tag to the program, so setup derives the channel from the running package's version. Unknown prerelease versions are rejected. An explicit local runtime opts out of npm updates. ## Flow @@ -54,4 +54,4 @@ T3 builds must contain support for `T3CODE_CODEX_LAUNCH_ARGS` in their server bu The npm launch command uses Node to run npm's CLI directly, including on Windows, avoiding batch-file quoting. It resolves the package channel at MCP startup and needs npm/network access. Verification must succeed before config is changed; there is no cached-version fallback during a failed install. -Running MCP processes and detached workers keep their loaded code. Automatic worker handoff is a separate planned change; this installer does not implement hot updates. Before upgrading a runtime with active watches, follow the existing update guidance in the README. +New MCP sessions resolve their npm channel and hand active watches to a newer worker after its current operation finishes. Existing MCP sessions keep their loaded code. See [update behavior](updates.md) for compatibility and recovery. diff --git a/docs/releases.md b/docs/releases.md index bdb0b0c..8209f9d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -30,15 +30,15 @@ All channels share one release concurrency group, and running releases are never ## Manual stable releases -Complete worker update/handoff testing before the first stable release. Until then, install with `npx t3poll@nightly setup`. +Promote stable only after the nightly passes worker handoff and package installation checks. ```sh gh workflow run release.yml --ref master -f channel=latest -f version=0.1.0 ``` -The workflow resolves the source commit recorded in the published nightly package, verifies that commit belongs to master, builds a stable version from it, and repeats the platform and package checks. It does not move the nightly prerelease itself to `latest`: the stable package version is needed so setup inherits the stable channel. The requested stable version must advance the current `latest` version. Omit `version` to use that commit's package.json base version. +The workflow resolves the source commit recorded in the published nightly package, verifies that commit belongs to master, builds a stable version from it, and repeats the platform and package checks. It does not move the nightly prerelease itself to `latest`: the stable package version is needed so setup inherits the stable channel. The requested stable version must advance the current `latest` version. Omit `version` to use the published nightly's base version. -If a future stable line needs a new base version for nightlies, update package.json and package-lock.json together on master. +After a stable promotion, nightlies automatically use the next patch base unless package.json already specifies a later version. This keeps future nightlies newer than the promoted stable worker. Update package.json and package-lock.json together to start a new minor or major line. ## First publication @@ -50,6 +50,6 @@ Keep the main checkout's version at its stable base. Release stamping changes on A published npm version is immutable. If publication succeeds but the GitHub release step fails, the npm package remains available; create its GitHub release from the recorded source commit and retained artifact rather than republishing that version. Check the npm registry before retrying a publication after a network timeout. -The installer resolves the npm channel when its MCP process starts. Running MCP processes and workers keep their loaded code. Automatic worker handoff is separate work; publishing a newer package does not upgrade an already running worker. The installer checks the package online at startup and has no explicit failed-download fallback. +The installer resolves the npm channel when its MCP process starts. New runtimes hand off active workers while preserving watch state. Existing MCP sessions keep their loaded code. See [update behavior](updates.md). Failed downloads leave an already running worker alone; there is no explicit failed-download fallback for starting a new MCP session. References: [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/), [npm trust CLI](https://docs.npmjs.com/cli/v11/commands/npm-trust/), [distribution tags](https://docs.npmjs.com/adding-dist-tags-to-packages/). diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..2b38cd1 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,25 @@ +# Updates + +Setup follows the invoked package's channel. New Codex sessions run npm with `--prefer-online` to resolve `latest` or `nightly`. Publishing alone does not change a running session. To load a channel now and hand over active watches, run: + +```sh +npx --yes --prefer-online t3poll@nightly list +``` + +Use `@latest` for stable. The command reports the worker PID and version. `updating: true` means the current operation is still draining. MCP startup also resumes saved watches and checks every 30 seconds for missing workers. These checks use the installed runtime; they do not query npm. + +## Handoff + +A candidate starts from a separate installed package and opens the state database before advertising its version. The current worker finishes its watch operation, including any in-flight request and persistence, then releases its lease. The candidate acquires the lease before processing watches. No process signal is needed, including on Windows. + +Watch IDs, expiration, snapshots, pending changes, and frozen commands stay in SQLite. An ambiguous dispatch retains exactly the same command and message IDs for retry. This relies on T3's existing command deduplication, just as ordinary network retries do. + +The state directory remembers the newest successfully loaded runtime's version and executable paths. Older sessions reuse that runtime even after an idle period. Version ordering follows release triples and nightly timestamps; a stable version follows nightlies with the same base. Switching channels does not downgrade a shared worker. Wait for a later release on the selected channel to advance it. Local builds with the same version do not trigger handoff. + +## Failures and compatibility + +A failed download or candidate that cannot load leaves the current worker alone. An incompatible database version fails before the candidate advertises itself. State schema version 1 is preserved with additive coordination tables, so this release does not rewrite watch records. + +If a candidate crashes after advertising readiness, saved watches remain intact. An attached MCP session retries worker startup within 30 seconds, or `list` retries immediately. Without an attached session, a failed successor may leave monitoring paused until the next command. The detached candidate waits until the old worker drains, even after the initiating CLI call returns. It exits if there is no work left or another candidate has already taken over. + +Keep the remembered package files installed while using that state directory. If npm's cache is manually cleared, run the same or a newer package again to restore a usable runtime. A runtime error appears in `list`; inspect `worker.log` under `T3POLL_HOME` for details. diff --git a/package-lock.json b/package-lock.json index 4fbf726..d19bdbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "t3poll", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t3poll", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@clack/prompts": "1.8.0", diff --git a/package.json b/package.json index e30c41a..dd4221a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "t3poll", - "version": "0.1.0", + "version": "0.1.1", "description": "Watch GitHub PRs and wake an existing T3 Code thread, through MCP or the CLI.", "type": "module", "license": "MIT", diff --git a/scripts/release.mjs b/scripts/release.mjs index 0104f93..55bf8b5 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -22,6 +22,17 @@ export function stableVersion(value, previous) { } return value; } +export function nightlyBase(base, latest) { + if (!latest || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(latest)) + return base; + try { + stableVersion(base, latest); + return base; + } catch { + const [major, minor, patch] = latest.split("."); + return `${major}.${minor}.${BigInt(patch) + 1n}`; + } +} export function nightlyCommit(metadata) { const commit = metadata?.t3pollRelease?.commit; if ( @@ -84,10 +95,12 @@ async function main() { ).version; const version = channel === "nightly" - ? nightlyVersion(base) + ? nightlyVersion(nightlyBase(base, metadata["dist-tags"].latest)) : stableVersion( - process.env.STABLE_VERSION || base, - metadata["dist-tags"].latest, + process.env.STABLE_VERSION || nightly.version.split("-")[0], + /^\d+\.\d+\.\d+$/.test(metadata["dist-tags"].latest ?? "") + ? metadata["dist-tags"].latest + : undefined, ); const skip = channel === "nightly" && diff --git a/src/mcp.ts b/src/mcp.ts index 0528cdc..e981ec1 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -85,6 +85,29 @@ export function createMcp(service: Service): McpServer { export async function serveMcp(service: Service): Promise { const server = createMcp(service); const transport = new StdioServerTransport(); - server.server.onclose = () => service.close(); + // Resume persisted watches on connection, and retry a failed worker without + // holding MCP initialization open or replacing the session's loaded code. + let checking = false; + let closed = false; + const check = async () => { + if (checking || closed) return; + checking = true; + try { + await service.list(); + } finally { + checking = false; + if (closed) service.close(); + } + }; + const timer = setInterval(() => { + void check().catch(() => {}); + }, 30_000); + timer.unref(); + server.server.onclose = () => { + closed = true; + clearInterval(timer); + if (!checking) service.close(); + }; await server.connect(transport); + void check().catch(() => {}); } diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..80c412c --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +export type Runtime = { version: string; cli: string; node: string }; +export const runtime: Runtime = { + version: JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ).version, + cli: fileURLToPath(new URL("./cli.js", import.meta.url)), + node: process.execPath, +}; + +// Published versions are stable triples or timestamped nightlies. Unknown development +// versions never displace a running release. +export function newer(a: string, b: string): boolean { + const parse = (v: string) => + /^(\d+)\.(\d+)\.(\d+)(?:-nightly\.(\d+))?$/.exec(v); + const x = parse(a), + y = parse(b); + if (!x || !y) return false; + for (let i = 1; i <= 3; i++) { + if (BigInt(x[i]!) !== BigInt(y[i]!)) return BigInt(x[i]!) > BigInt(y[i]!); + } + if (!x[4] || !y[4]) return !x[4] && !!y[4]; + return BigInt(x[4]) > BigInt(y[4]); +} diff --git a/src/store.ts b/src/store.ts index bc4e5b2..581aad0 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,6 +2,7 @@ import { privateDirectory, protectFile } from "./private-files.js"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import type { Watch } from "./model.js"; +import { newer, type Runtime } from "./runtime.js"; export class Store { readonly db: DatabaseSync; @@ -17,6 +18,8 @@ export class Store { this.db.exec(` CREATE TABLE IF NOT EXISTS watches (id TEXT PRIMARY KEY, watch_key TEXT UNIQUE NOT NULL, revision INTEGER NOT NULL, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS worker (id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT NOT NULL, pid INTEGER NOT NULL, expires INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS runtime_target (id INTEGER PRIMARY KEY CHECK(id=1), data TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS worker_runtime (owner TEXT PRIMARY KEY, version TEXT NOT NULL); PRAGMA user_version=1; `); } @@ -67,13 +70,50 @@ export class Store { (w) => w.status === "watching" || w.status === "finishing", ); } - lease(owner: string, pid: number, now: number): boolean { - const result = this.db - .prepare( - `INSERT INTO worker VALUES(1,?,?,?) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,pid=excluded.pid,expires=excluded.expires WHERE worker.expires < ? OR worker.owner=?`, + target(): Runtime | undefined { + const row = this.db + .prepare("SELECT data FROM runtime_target WHERE id=1") + .get(); + return row ? (JSON.parse(String(row.data)) as Runtime) : undefined; + } + offer(runtime: Runtime): void { + this.db.exec("BEGIN IMMEDIATE"); + try { + const previous = this.target(); + if ( + !previous || + runtime.version === previous.version || + newer(runtime.version, previous.version) ) - .run(owner, pid, now + 60_000, now, owner); - return Number(result.changes) > 0; + this.db + .prepare( + "INSERT INTO runtime_target VALUES(1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data", + ) + .run(JSON.stringify(runtime)); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + lease(owner: string, pid: number, now: number, version?: string): boolean { + this.db.exec("BEGIN IMMEDIATE"); + try { + const result = this.db + .prepare( + `INSERT INTO worker VALUES(1,?,?,?) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,pid=excluded.pid,expires=excluded.expires WHERE worker.expires < ? OR worker.owner=?`, + ) + .run(owner, pid, now + 60_000, now, owner); + if (Number(result.changes) > 0 && version) + this.db + .prepare("INSERT OR REPLACE INTO worker_runtime VALUES(?,?)") + .run(owner, version); + this.db.exec("COMMIT"); + return Number(result.changes) > 0; + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } } owns(owner: string): boolean { return ( @@ -81,16 +121,23 @@ export class Store { owner ); } - worker(): { pid: number; expires: number } | undefined { + worker(): { pid: number; expires: number; version?: string } | undefined { const row = this.db - .prepare("SELECT pid,expires FROM worker WHERE id=1") + .prepare( + "SELECT pid,expires,version FROM worker LEFT JOIN worker_runtime USING(owner) WHERE id=1", + ) .get(); return row - ? { pid: Number(row.pid), expires: Number(row.expires) } + ? { + pid: Number(row.pid), + expires: Number(row.expires), + ...(row.version ? { version: String(row.version) } : {}), + } : undefined; } release(owner: string): void { this.db.prepare("DELETE FROM worker WHERE owner=?").run(owner); + this.db.prepare("DELETE FROM worker_runtime WHERE owner=?").run(owner); } retire(owner: string): boolean { return ( diff --git a/src/worker.ts b/src/worker.ts index ff897dd..3e01555 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { openSync, closeSync } from "node:fs"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { runtime, newer } from "./runtime.js"; import { setTimeout as delay } from "node:timers/promises"; import { Store } from "./store.js"; import { readGithub } from "./github.js"; @@ -160,9 +160,21 @@ export async function tick( export async function runWorker(home: string): Promise { const store = new Store(home); const owner = randomUUID(); - if (!store.lease(owner, process.pid, Date.now())) { - store.close(); - return; + // Only a successfully loaded candidate advertises itself. A broken installation + // cannot ask the healthy worker to retire. + store.offer(runtime); + while (!store.lease(owner, process.pid, Date.now(), runtime.version)) { + const current = store.worker(); + if (current && !alive(current.pid)) store.clearDeadWorker(current.pid); + if ( + !store.work().length || + newer(store.target()!.version, runtime.version) || + (current?.version && !newer(runtime.version, current.version)) + ) { + store.close(); + return; + } + await delay(100); } let stopping = false; const stop = () => { @@ -171,10 +183,12 @@ export async function runWorker(home: string): Promise { process.on("SIGTERM", stop); process.on("SIGINT", stop); const heartbeat = setInterval(() => { - if (!store.lease(owner, process.pid, Date.now())) stopping = true; + if (!store.lease(owner, process.pid, Date.now(), runtime.version)) + stopping = true; }, 5_000); try { while (!stopping && store.owns(owner)) { + if (newer(store.target()!.version, runtime.version)) break; const work = store.work(); if (!work.length && store.retire(owner)) break; for (const watch of work) { @@ -185,6 +199,7 @@ export async function runWorker(home: string): Promise { dependencies, () => !stopping && store.owns(owner), ); + if (newer(store.target()!.version, runtime.version)) break; } if (!stopping) await delay(1000); } @@ -206,40 +221,66 @@ function alive(pid: number): boolean { } } -export async function ensureWorker( - store: Store, -): Promise<{ pid: number | null }> { +export async function ensureWorker(store: Store): Promise<{ + pid: number | null; + version?: string; + updating?: boolean; + error?: string; +}> { if (!store.work().length) return { pid: null }; const previous = store.worker(); - if (previous && alive(previous.pid) && previous.expires > Date.now()) - return { pid: previous.pid }; + const target = store.target(); + const selected = + target && newer(target.version, runtime.version) ? target : runtime; + if (previous && alive(previous.pid) && previous.expires > Date.now()) { + if (previous.version && !newer(selected.version, previous.version)) + return { pid: previous.pid, version: previous.version }; + } if (previous && !alive(previous.pid)) store.clearDeadWorker(previous.pid); const log = openSync(join(store.home, "worker.log"), "a", 0o600); - const child = spawn( - process.execPath, - [fileURLToPath(new URL("./cli.js", import.meta.url)), "_worker"], - { - detached: true, - windowsHide: true, - stdio: ["ignore", log, log], - cwd: store.home, - env: { ...process.env, T3POLL_HOME: store.home }, - }, - ); + const child = spawn(selected.node, [selected.cli, "_worker"], { + detached: true, + windowsHide: true, + stdio: ["ignore", log, log], + cwd: store.home, + env: { ...process.env, T3POLL_HOME: store.home }, + }); closeSync(log); let spawnError = false; + let exited = false; + child.on("exit", () => { + exited = true; + }); child.on("error", () => { spawnError = true; }); child.unref(); for (let i = 0; i < 80; i++) { - if (spawnError) break; const worker = store.worker(); - if (worker && worker.expires > Date.now() && alive(worker.pid)) - return { pid: worker.pid }; + if ( + worker && + worker.expires > Date.now() && + alive(worker.pid) && + worker.version && + !newer(selected.version, worker.version) + ) + return { pid: worker.pid, version: worker.version }; if (!store.work().length) return { pid: null }; + if (spawnError || exited) break; await delay(100); } + const current = store.worker(); + if (current && current.expires > Date.now() && alive(current.pid)) + return { + pid: current.pid, + version: current.version, + ...(!spawnError && !exited + ? { updating: true } + : { + error: + "Worker update failed; the existing worker is still running. Run t3poll list to retry; inspect worker.log in T3POLL_HOME.", + }), + }; throw new Error( "Watch saved, but worker startup failed. Run t3poll list to retry startup; inspect worker.log in T3POLL_HOME.", ); diff --git a/tests/process.test.ts b/tests/process.test.ts index f5831c2..812aa1d 100644 --- a/tests/process.test.ts +++ b/tests/process.test.ts @@ -1,7 +1,8 @@ +import { rm } from "node:fs/promises"; import { executable } from "./fixtures/executable.mjs"; import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve, delimiter } from "node:path"; import { createServer } from "node:http"; @@ -179,7 +180,12 @@ process.stdout.write(JSON.stringify(result)); await until(() => !store.worker()).catch(() => {}); store.close(); await new Promise((resolve) => server.close(() => resolve())); - rmSync(home, { recursive: true, force: true }); + await rm(home, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }); } }, ); diff --git a/tests/release.test.ts b/tests/release.test.ts index 5bf8ec6..e37e83a 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { nightlyVersion, + nightlyBase, stableVersion, nightlyCommit, } from "../scripts/release.mjs"; @@ -34,3 +35,11 @@ test("stable promotion requires published nightly source metadata", () => { ]) assert.throws(() => nightlyCommit(metadata)); }); + +test("nightlies advance past stable promotions without a manual base bump", () => { + assert.equal(nightlyBase("0.1.1", undefined), "0.1.1"); + assert.equal(nightlyBase("0.1.1", "0.1.1"), "0.1.2"); + assert.equal(nightlyBase("0.1.1", "0.2.0"), "0.2.1"); + assert.equal(nightlyBase("0.3.0", "0.2.0"), "0.3.0"); + assert.equal(nightlyBase("0.1.1", "0.1.0-nightly.1"), "0.1.1"); +}); diff --git a/tests/updates.test.ts b/tests/updates.test.ts new file mode 100644 index 0000000..ed0a133 --- /dev/null +++ b/tests/updates.test.ts @@ -0,0 +1,249 @@ +import { rm } from "node:fs/promises"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + cpSync, + readFileSync, + writeFileSync, + mkdirSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { createServer } from "node:http"; +import { setTimeout as delay } from "node:timers/promises"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { protectFile } from "../src/private-files.js"; +import { Store } from "../src/store.js"; +import { newer } from "../src/runtime.js"; +import type { Watch, Command } from "../src/model.js"; +const exec = promisify(execFile); +async function until(check: () => boolean) { + const end = Date.now() + 12000; + while (!check()) { + if (Date.now() > end) + throw new Error("Timed out waiting for worker handoff"); + await delay(25); + } +} + +test("release ordering prevents old sessions from downgrading workers", () => { + assert.equal(newer("0.1.1-nightly.20260912000000", "0.1.0"), true); + assert.equal(newer("0.1.1", "0.1.1-nightly.20260912000000"), true); + assert.equal(newer("0.1.1-nightly.20260912000000", "0.1.1"), false); + assert.equal( + newer("0.1.1-nightly.20260912000001", "0.1.1-nightly.20260912000000"), + true, + ); + assert.equal(newer("dev", "0.1.1"), false); +}); + +for (const ambiguous of [false, true]) + test( + `worker upgrade preserves ${ambiguous ? "ambiguous" : "accepted"} in-flight delivery and rejects downgrades and broken candidates`, + { timeout: 60000 }, + async () => { + const home = mkdtempSync(join(tmpdir(), "t3poll upgrade café ")); + function build(name: string, version: string) { + const dir = join(home, name); + mkdirSync(dir); + cpSync(resolve("dist"), join(dir, "dist"), { recursive: true }); + symlinkSync( + resolve("node_modules"), + join(dir, "node_modules"), + "junction", + ); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ type: "module", version }), + ); + return join(dir, "dist", "cli.js"); + } + const old = build("old", "0.1.1-nightly.20260912000000"); + const next = build("next", "0.1.1-nightly.20260912000001"); + const broken = build("broken", "0.1.2"); + writeFileSync( + broken, + readFileSync(broken, "utf8").replace( + "await runWorker(config.home);", + "throw new Error('broken candidate');", + ), + ); + const env = { ...process.env, T3POLL_HOME: join(home, "state") }; + const list = async (cli: string) => + JSON.parse( + (await exec(process.execPath, [cli, "list"], { env })).stdout, + ); + const commands: Command[] = []; + let finish: (() => void) | undefined; + const server = createServer(async (req, res) => { + res.setHeader("Content-Type", "application/json"); + if (req.url === "/api/orchestration/shell") { + res.end( + JSON.stringify({ + threads: [ + { + id: "thread", + title: "Review", + runtimeMode: "approval-required", + interactionMode: "default", + archivedAt: null, + latestTurn: null, + session: null, + }, + ], + }), + ); + } else { + let body = ""; + for await (const chunk of req) body += chunk; + commands.push(JSON.parse(body)); + if (commands.length === 1) + finish = () => { + res.statusCode = ambiguous ? 503 : 200; + res.end(JSON.stringify({ sequence: 1 })); + }; + else res.end(JSON.stringify({ sequence: commands.length })); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const tokenFile = join(home, "token"); + writeFileSync(tokenFile, "test-token", { mode: 0o600 }); + protectFile(tokenFile); + const store = new Store(env.T3POLL_HOME); + const now = Date.now(); + const watch: Watch = { + id: "watch", + key: "key", + revision: 0, + pr: "https://github.com/owner/repo/pull/1", + threadId: "thread", + threadTitle: "Review", + origin: `http://127.0.0.1:${address.port}`, + tokenFile, + intervalSeconds: 60, + status: "watching", + createdAt: now, + expiresAt: now + 3600000, + nextPoll: now + 3600000, + lastPoll: now, + snapshot: { head: "a".repeat(40), state: "open", entries: {} }, + pending: [ + { + key: "comment:1", + text: "New comment", + url: "https://github.com/owner/repo/pull/1", + }, + ], + command: null, + nextDelivery: 0, + pollFailures: 0, + deliveryFailures: 0, + lastError: null, + deliveryError: null, + lastDelivery: null, + }; + store.add(watch); + try { + const initial = await list(old); + await until(() => commands.length === 1); + const frozen = store.get(watch.id)!.command; + assert.ok(frozen); + const upgrading = Promise.all([list(next), list(next)]); + await until( + () => store.target()?.version === "0.1.1-nightly.20260912000001", + ); + assert.equal(store.worker()!.pid, initial.worker.pid); + assert.deepEqual(store.get(watch.id)!.command, frozen); + assert.equal(commands.length, 1); + if (!ambiguous) { + // The one-shot callers return while the worker is still draining. + // Their detached candidates must remain ready to take over afterward. + const waiting = await upgrading; + assert.ok(waiting.every((result) => result.worker.updating)); + assert.equal(store.worker()!.pid, initial.worker.pid); + finish!(); + await until( + () => store.worker()?.version === "0.1.1-nightly.20260912000001", + ); + } else finish!(); + const [upgraded, concurrent] = ambiguous + ? await upgrading + : await Promise.all([list(next), list(next)]); + assert.equal(upgraded.worker.pid, concurrent.worker.pid); + assert.notEqual(upgraded.worker.pid, initial.worker.pid); + assert.equal(upgraded.worker.version, "0.1.1-nightly.20260912000001"); + if (ambiguous) { + const retry = store.get(watch.id)!; + assert.deepEqual(retry.command, frozen); + retry.nextDelivery = 0; + store.save(retry); + await until(() => store.get(watch.id)!.lastDelivery !== null); + assert.deepEqual(commands[1], commands[0]); + } + const saved = store.get(watch.id)!; + assert.equal(saved.id, watch.id); + assert.equal(saved.expiresAt, watch.expiresAt); + assert.deepEqual(saved.snapshot, watch.snapshot); + assert.equal(saved.command, null); + assert.equal(saved.lastDelivery!.messageId, frozen.message.messageId); + assert.equal(commands.length, ambiguous ? 2 : 1); + assert.equal((await list(old)).worker.pid, upgraded.worker.pid); + const failedUpdate = await list(broken); + assert.match(failedUpdate.worker.error, /Worker update failed/); + assert.equal(failedUpdate.worker.pid, upgraded.worker.pid); + assert.equal(store.worker()!.pid, upgraded.worker.pid); + assert.equal(store.target()!.version, upgraded.worker.version); + // Retained runtime also wins when an old session starts a worker after idle. + store.stop(watch.id); + await until(() => !store.worker()); + const resumed = store.get(watch.id)!; + resumed.status = "watching"; + resumed.pending = [{ key: "comment:2", text: "Queued", url: watch.pr }]; + resumed.nextDelivery = Date.now() + 60000; + store.save(resumed); + const restarted = await list(old); + assert.equal(restarted.worker.version, upgraded.worker.version); + assert.deepEqual(store.get(watch.id)!.pending, resumed.pending); + store.stop(watch.id); + await until(() => !store.worker()); + const persisted = store.get(watch.id)!; + persisted.status = "watching"; + store.save(persisted); + const client = new Client({ name: "startup-test", version: "1" }); + try { + await client.connect( + new StdioClientTransport({ + command: process.execPath, + args: [old, "mcp"], + env: env as Record, + stderr: "pipe", + }), + ); + await until(() => !!store.worker()); + assert.equal(store.worker()!.version, upgraded.worker.version); + } finally { + await client.close(); + } + } finally { + finish?.(); + for (const w of store.work()) store.stop(w.id); + await until(() => !store.worker()).catch(() => {}); + store.close(); + server.closeAllConnections(); + await new Promise((r) => server.close(() => r())); + await rm(home, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }); + } + }, + );