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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .plans/npm-distribution.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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/).
2 changes: 1 addition & 1 deletion .plans/setup-and-release.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
20 changes: 3 additions & 17 deletions .plans/worker-updates.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 7 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
8 changes: 4 additions & 4 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/).
25 changes: 25 additions & 0 deletions docs/updates.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 16 additions & 3 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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" &&
Expand Down
25 changes: 24 additions & 1 deletion src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,29 @@ export function createMcp(service: Service): McpServer {
export async function serveMcp(service: Service): Promise<void> {
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(() => {});
}
26 changes: 26 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading
Loading