From 9d6b5ea2b6b14d398f6fcb95685463e102d354e3 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:31:09 +0000
Subject: [PATCH 001/149] feat(dev): add an update lab for multi-environment
update testing
Provider latest-version checks honour npm_config_registry, and t3 update reads the release index from T3CODE_RELEASE_BASE_URL when set, so the lab (and npm or release mirrors) can serve both.
---
apps/server/src/cli/update.ts | 8 +-
.../src/server/maintenanceResolver.ts | 13 +-
packages/shared/src/cliRelease.ts | 11 +-
scripts/update-lab.ts | 1198 +++++++++++++++++
4 files changed, 1224 insertions(+), 6 deletions(-)
create mode 100644 scripts/update-lab.ts
diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts
index 576158e316bc2..fa167e3bdeeb7 100644
--- a/apps/server/src/cli/update.ts
+++ b/apps/server/src/cli/update.ts
@@ -64,12 +64,13 @@ const RELEASE_INDEX_MAX_PAGES = 10;
/** Asks GitHub for the newest published version on a channel, page by page. */
const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
channel: CliReleaseChannel,
+ releaseBaseUrl: string | undefined,
) {
const httpClient = yield* HttpClient.HttpClient;
for (let page = 1; page <= RELEASE_INDEX_MAX_PAGES; page += 1) {
const body = yield* httpClient
.execute(
- HttpClientRequest.get(cliReleaseIndexPageUrl(page)).pipe(
+ HttpClientRequest.get(cliReleaseIndexPageUrl(page, releaseBaseUrl)).pipe(
HttpClientRequest.setHeader("Accept", "application/vnd.github+json"),
),
)
@@ -347,6 +348,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
const service = yield* BootService.BootService;
const currentVersion = packageJson.version;
+ const releaseBaseUrl = environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined;
const channel = input.channel ?? cliReleaseChannelOf(currentVersion);
if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) {
return yield* new CliUpdateError({
@@ -357,7 +359,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
progress.status("Checking for updates...");
const targetVersion = yield* (
input.requestedVersion === undefined
- ? resolveNewestVersion(channel)
+ ? resolveNewestVersion(channel, releaseBaseUrl)
: Effect.succeed(input.requestedVersion)
).pipe(Effect.ensuring(Effect.sync(progress.finish)));
const targetChannel = cliReleaseChannelOf(targetVersion);
@@ -491,7 +493,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
httpClient,
platform,
arch,
- releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined,
+ releaseBaseUrl,
validate: (paths) =>
runner
.run({
diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts
index 65667281ac6a9..ae6352b2abeaf 100644
--- a/packages/provider-core/src/server/maintenanceResolver.ts
+++ b/packages/provider-core/src/server/maintenanceResolver.ts
@@ -750,10 +750,21 @@ export function createProviderVersionAdvisory(input: {
};
}
+const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org";
+
+/** npm's own registry override, so mirrors (and local fake registries) answer "latest". */
+const NpmRegistryUrlConfig = Config.String("npm_config_registry").pipe(
+ Config.withDefault(DEFAULT_NPM_REGISTRY_URL),
+ Config.map((url) => url.trim().replace(/\/+$/, "") || DEFAULT_NPM_REGISTRY_URL),
+);
+
const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) {
const client = yield* HttpClient.HttpClient;
+ const registryUrl = yield* NpmRegistryUrlConfig.pipe(
+ Effect.orElseSucceed(() => DEFAULT_NPM_REGISTRY_URL),
+ );
const request = HttpClientRequest.get(
- `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
+ `${registryUrl}/${encodeURIComponent(packageName)}/latest`,
).pipe(HttpClientRequest.setHeader("accept", "application/json"));
const response = yield* client.execute(request).pipe(
Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS),
diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts
index 28f0d530bb297..69d0daddb4fc0 100644
--- a/packages/shared/src/cliRelease.ts
+++ b/packages/shared/src/cliRelease.ts
@@ -96,9 +96,16 @@ export function cliReleaseChannelOf(version: string): CliReleaseChannel {
* One page of GitHub's list-releases endpoint, newest first. Callers walk pages
* until a channel match turns up; a busy nightly train can push the newest
* preview or stable release past any single page.
+ *
+ * A mirror set through `T3CODE_RELEASE_BASE_URL` serves the same JSON (an
+ * array of `{ tag_name, draft? }`, newest first, empty past the last page) at
+ * `/index.json?per_page=100&page=N`.
*/
-export function cliReleaseIndexPageUrl(page: number): string {
- return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`;
+export function cliReleaseIndexPageUrl(page: number, baseUrl?: string): string {
+ const mirror = baseUrl?.trim().replace(/\/+$/, "");
+ return mirror
+ ? `${mirror}/index.json?per_page=100&page=${page}`
+ : `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`;
}
/**
diff --git a/scripts/update-lab.ts b/scripts/update-lab.ts
new file mode 100644
index 0000000000000..5f70c4f84f9e7
--- /dev/null
+++ b/scripts/update-lab.ts
@@ -0,0 +1,1198 @@
+#!/usr/bin/env node
+// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off globalFetch:off globalConsole:off preferSchemaOverJson:off - Host-side dev lab that spawns isolated servers and fake CLIs with Node APIs directly.
+/**
+ * Update lab: several isolated T3 servers ("machines") with fake provider
+ * CLIs, a fake npm registry and a fake t3 release feed, for exercising
+ * provider, server and launcher update flows end to end. State lives in the
+ * gitignored `/.t3/update-lab`. Fake CLIs and package managers re-enter
+ * this file as `__tool`, and the registry/feed server as `__feed`, so the lab
+ * stays one file. Run `node scripts/update-lab.ts --help`.
+ */
+import * as NodeChildProcess from "node:child_process";
+import * as NodeCrypto from "node:crypto";
+import * as NodeFS from "node:fs";
+import * as NodeHttp from "node:http";
+import * as NodeNet from "node:net";
+import * as NodePath from "node:path";
+import * as NodeReadline from "node:readline";
+
+const REPO = NodePath.resolve(import.meta.dirname, "..");
+const LAB = NodePath.join(REPO, ".t3", "update-lab");
+const STATE_PATH = NodePath.join(LAB, "state.json");
+const RELEASES = NodePath.join(LAB, "releases");
+const SERVER_BIN = NodePath.join(REPO, "apps/server/src/bin.ts");
+const MOCK_ACP_AGENT = NodePath.join(REPO, "apps/server/scripts/acp-mock-agent.ts");
+const NODE = process.execPath;
+const SELF = import.meta.filename;
+const REPO_VERSION: string = JSON.parse(
+ NodeFS.readFileSync(NodePath.join(REPO, "apps/server/package.json"), "utf8"),
+).version;
+// oxlint-disable-next-line t3code/no-global-process-runtime -- Plain Node dev script; the lab only targets this host.
+const PLATFORM_KEY = `${process.platform}-${process.arch}`;
+const ORCHESTRATION_PROTOCOL = "2";
+
+const HELP = `Usage: node scripts/update-lab.ts
+
+Isolated T3 servers with fake provider CLIs, a fake npm registry and a fake
+t3 release feed, all under .t3/update-lab (state survives down/up).
+
+ up [--envs N] [--host IP] [--launcher N] [--from-version V] [--busy-seconds S]
+ Start the feed and N servers (default 3; host 127.0.0.1). The last N
+ envs (default 1) run under the real service launcher on runtime V
+ (default: repo version minus one patch) so self-update can be tried.
+ Prints each env's origin and one-time pairing URL. Rerun to resume.
+ down Stop only the processes the lab recorded.
+ status Envs, ports, liveness, installed vs latest.
+ pair Mint a fresh pairing URL for env (e.g. env1).
+ publish Set the registry's latest for a provider.
+ fail on|off Make that env's installer/updater exit 1.
+ delay Installer/updater duration (default 3000).
+ providers Print the provider list clients see for env.
+ update [v] Run server.updateProvider like the UI does.
+ busy Start a mock-acp turn that runs --busy-seconds.
+ self-update Run server.updateServer (launcher envs only).
+ build-runtime [--archive file]
+ Publish a t3 runtime to the release feed: a source runtime (copy of
+ apps/server stamped with , runs on this checkout's deps) or
+ a real CLI archive built by the release pipeline.
+ reset Delete the lab (only after down).
+
+Providers: codex claude opencode pi grok. Install methods rotate across envs:
+npm prefix, pnpm, bun, yarn, volta, vite-plus, Homebrew formula and cask,
+native installers (claude update, codex standalone, opencode upgrade,
+grok update, pi update --self) and mise (manual-only). Each env also has a
+"mock-acp" provider (apps/server/scripts/acp-mock-agent.ts) for busy turns.
+Servers cache npm "latest" for an hour: down && up after publish.
+Not covered: cursor, antigravity, muse (no npm advisory or network-only).`;
+
+type ProviderKey = "codex" | "claude" | "opencode" | "pi" | "grok";
+type Method =
+ | "npm"
+ | "pnpm"
+ | "bun"
+ | "yarn"
+ | "volta"
+ | "vite-plus"
+ | "homebrew"
+ | "native"
+ | "mise";
+
+const PROVIDERS: Record<
+ ProviderKey,
+ {
+ readonly command: string;
+ readonly pkg: string;
+ readonly driver: string;
+ readonly installed: string;
+ readonly latest: string;
+ readonly brew: { readonly kind: "formula" | "cask"; readonly name: string } | null;
+ }
+> = {
+ codex: {
+ command: "codex",
+ pkg: "@openai/codex",
+ driver: "codex",
+ installed: "0.158.0",
+ latest: "0.160.0",
+ brew: { kind: "formula", name: "codex" },
+ },
+ claude: {
+ command: "claude",
+ pkg: "@anthropic-ai/claude-code",
+ driver: "claudeAgent",
+ installed: "2.1.285",
+ latest: "2.1.290",
+ brew: { kind: "cask", name: "claude-code" },
+ },
+ opencode: {
+ command: "opencode",
+ pkg: "opencode-ai",
+ driver: "opencode",
+ installed: "1.14.19",
+ latest: "1.14.25",
+ brew: { kind: "formula", name: "opencode" },
+ },
+ pi: {
+ command: "pi",
+ pkg: "@earendil-works/pi-coding-agent",
+ driver: "pi",
+ installed: "0.80.6",
+ latest: "0.81.0",
+ brew: null,
+ },
+ grok: {
+ command: "grok",
+ pkg: "@xai-official/grok",
+ driver: "grok",
+ installed: "1.0.13",
+ latest: "1.0.20",
+ brew: null,
+ },
+};
+const PROVIDER_KEYS = Object.keys(PROVIDERS) as ProviderKey[];
+
+// Env N uses row (N-1) % rows; three envs already reach every resolver branch.
+const INSTALL_MATRIX: ReadonlyArray> = [
+ { codex: "npm", claude: "native", opencode: "homebrew", pi: "pnpm", grok: "native" },
+ { codex: "native", claude: "homebrew", opencode: "bun", pi: "native", grok: "npm" },
+ { codex: "volta", claude: "yarn", opencode: "native", pi: "vite-plus", grok: "bun" },
+ { codex: "mise", claude: "pnpm", opencode: "npm", pi: "yarn", grok: "native" },
+ { codex: "vite-plus", claude: "bun", opencode: "volta", pi: "npm", grok: "pnpm" },
+];
+
+// The package manager each fake tool stands in for.
+const TOOL_METHOD: Record = {
+ npm: "npm",
+ pnpm: "pnpm",
+ bun: "bun",
+ yarn: "yarn",
+ volta: "volta",
+ vp: "vite-plus",
+ brew: "homebrew",
+};
+
+interface Install {
+ method: Method;
+ version: string;
+ failing: boolean;
+}
+
+interface LabEnv {
+ id: string;
+ port: number;
+ pid: number | null;
+ launcher: boolean;
+ projectId: string | null;
+ installs: Record;
+}
+
+interface LabState {
+ host: string;
+ feedPort: number;
+ feedPid: number | null;
+ delayMs: number;
+ busySeconds: number;
+ fromVersion: string;
+ latest: Record;
+ envs: LabEnv[];
+}
+
+// ---------------------------------------------------------------------------
+// State
+
+function readState(): LabState | null {
+ if (!NodeFS.existsSync(STATE_PATH)) return null;
+ return JSON.parse(NodeFS.readFileSync(STATE_PATH, "utf8"));
+}
+
+function requireState(): LabState {
+ const state = readState();
+ if (!state) fail("No lab yet. Run `node scripts/update-lab.ts up` first.");
+ return state;
+}
+
+/** Fake installers and the CLI write concurrently, so writes are atomic renames. */
+function writeState(state: LabState): void {
+ const temp = `${STATE_PATH}.${process.pid}.tmp`;
+ NodeFS.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`);
+ NodeFS.renameSync(temp, STATE_PATH);
+}
+
+function updateState(mutate: (state: LabState) => void): LabState {
+ const state = requireState();
+ mutate(state);
+ writeState(state);
+ return state;
+}
+
+function requireEnv(state: LabState, id: string | undefined): LabEnv {
+ const env = state.envs.find((candidate) => candidate.id === id);
+ if (!env) fail(`Unknown env ${id ?? "(none)"}. Envs: ${state.envs.map((e) => e.id).join(", ")}`);
+ return env;
+}
+
+function requireProvider(name: string | undefined): ProviderKey {
+ if (name && name in PROVIDERS) return name as ProviderKey;
+ fail(`Unknown provider ${name ?? "(none)"}. Providers: ${PROVIDER_KEYS.join(", ")}`);
+}
+
+function fail(message: string): never {
+ process.stderr.write(`${message}\n`);
+ process.exit(1);
+}
+
+const envDir = (id: string) => NodePath.join(LAB, "envs", id);
+const homeDir = (id: string) => NodePath.join(envDir(id), "home");
+const t3Home = (id: string) => NodePath.join(envDir(id), "t3");
+const projectDir = (id: string) => NodePath.join(envDir(id), "project");
+const logPath = (id: string) => NodePath.join(envDir(id), "server.log");
+const originOf = (state: LabState, env: LabEnv) => `http://${state.host}:${env.port}`;
+
+// ---------------------------------------------------------------------------
+// Fake installs
+
+function writeExecutable(path: string, content: string): void {
+ NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
+ NodeFS.writeFileSync(path, content, { mode: 0o755 });
+}
+
+function link(target: string, path: string): void {
+ NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
+ NodeFS.rmSync(path, { force: true });
+ NodeFS.symlinkSync(target, path);
+}
+
+/** A shell shim that re-enters this file as a fake tool; `$0` names a shared volta shim. */
+function toolShim(envId: string, tool: string | null): string {
+ const name = tool === null ? `"$(basename "$0")"` : `'${tool}'`;
+ return `#!/bin/sh\nexec '${NODE}' '${SELF}' __tool '${envId}' ${name} "$@"\n`;
+}
+
+/** Search order shared by the server's PATH and the fake login shell profile. */
+function envPath(envId: string): string {
+ const home = homeDir(envId);
+ return [
+ ".local/bin",
+ ".npm-global/bin",
+ ".local/share/pnpm",
+ ".bun/bin",
+ ".yarn/bin",
+ ".volta/bin",
+ ".vite-plus/bin",
+ "homebrew/bin",
+ ".opencode/bin",
+ ".grok/bin",
+ ".pi/bin",
+ ".local/share/mise/shims",
+ ]
+ .map((dir) => NodePath.join(home, dir))
+ .concat([NodePath.join(envDir(envId), "tools"), "/usr/local/bin", "/usr/bin", "/bin"])
+ .join(":");
+}
+
+/** Lay a provider out the way its installer would, so the server's resolver sees real paths. */
+function layOutInstall(envId: string, provider: ProviderKey, method: Method): void {
+ const home = homeDir(envId);
+ const { command, pkg, brew } = PROVIDERS[provider];
+ const shim = toolShim(envId, command);
+ const at = (...parts: string[]) => NodePath.join(home, ...parts);
+ switch (method) {
+ case "npm": {
+ const real = at(".npm-global/lib/node_modules", pkg, "bin", `${command}.js`);
+ writeExecutable(real, shim);
+ link(real, at(".npm-global/bin", command));
+ return;
+ }
+ case "yarn": {
+ const real = at(".config/yarn/global/node_modules", pkg, "bin", `${command}.js`);
+ writeExecutable(real, shim);
+ link(real, at(".yarn/bin", command));
+ return;
+ }
+ case "pnpm":
+ return writeExecutable(at(".local/share/pnpm", command), shim);
+ case "bun":
+ return writeExecutable(at(".bun/bin", command), shim);
+ case "vite-plus":
+ return writeExecutable(at(".vite-plus/bin", command), shim);
+ case "volta":
+ writeExecutable(at(".volta/bin/volta-shim"), toolShim(envId, null));
+ writeExecutable(at(".volta/bin/volta"), toolShim(envId, "volta"));
+ NodeFS.mkdirSync(at(".volta/tools/image/packages", ...pkg.split("/")), { recursive: true });
+ link(at(".volta/bin/volta-shim"), at(".volta/bin", command));
+ return;
+ case "homebrew": {
+ if (!brew) fail(`${provider} has no Homebrew package.`);
+ const keg =
+ brew.kind === "formula"
+ ? at("homebrew/Cellar", brew.name, PROVIDERS[provider].installed, "bin", command)
+ : at("homebrew/Caskroom", brew.name, PROVIDERS[provider].installed, command);
+ writeExecutable(keg, shim);
+ link(keg, at("homebrew/bin", command));
+ writeExecutable(at("homebrew/bin/brew"), toolShim(envId, "brew"));
+ return;
+ }
+ case "mise": {
+ const real = at(".local/share/mise/installs", command, "latest", "bin", command);
+ writeExecutable(real, shim);
+ link(real, at(".local/share/mise/shims", command));
+ return;
+ }
+ case "native":
+ switch (provider) {
+ case "claude":
+ return writeExecutable(at(".local/bin/claude"), shim);
+ case "codex": {
+ const real = at(".codex/packages/standalone/current/bin/codex");
+ writeExecutable(real, shim);
+ link(real, at(".local/bin/codex"));
+ return;
+ }
+ case "opencode":
+ return writeExecutable(at(".opencode/bin/opencode"), shim);
+ case "grok":
+ return writeExecutable(at(".grok/bin/grok"), shim);
+ case "pi":
+ return writeExecutable(at(".pi/bin/pi"), shim);
+ }
+ }
+}
+
+function seedEnvFiles(state: LabState, env: LabEnv): void {
+ const home = homeDir(env.id);
+ NodeFS.mkdirSync(home, { recursive: true });
+ // The server reads PATH from a login shell first; these dotfiles keep it on the lab PATH.
+ const profile = `export PATH='${envPath(env.id)}'\n`;
+ for (const file of [".bash_profile", ".bashrc", ".profile", ".zprofile", ".zshrc"]) {
+ NodeFS.writeFileSync(NodePath.join(home, file), profile);
+ }
+ const tools = NodePath.join(envDir(env.id), "tools");
+ for (const tool of ["npm", "pnpm", "bun", "yarn", "vp"]) {
+ writeExecutable(NodePath.join(tools, tool), toolShim(env.id, tool));
+ }
+ link(NODE, NodePath.join(tools, "node"));
+ for (const provider of PROVIDER_KEYS) {
+ layOutInstall(env.id, provider, env.installs[provider].method);
+ }
+
+ const project = projectDir(env.id);
+ if (!NodeFS.existsSync(NodePath.join(project, ".git"))) {
+ NodeFS.mkdirSync(project, { recursive: true });
+ NodeFS.writeFileSync(NodePath.join(project, "README.md"), `# ${env.id} lab project\n`);
+ const git = (...args: string[]) =>
+ NodeChildProcess.execFileSync("git", ["-C", project, ...args], { stdio: "ignore" });
+ git("init", "-q");
+ git("add", ".");
+ git("-c", "user.name=Update Lab", "-c", "user.email=lab@localhost", "commit", "-qm", "init");
+ }
+
+ const settingsPath = NodePath.join(t3Home(env.id), "userdata", "settings.json");
+ NodeFS.mkdirSync(NodePath.dirname(settingsPath), { recursive: true });
+ const settings = NodeFS.existsSync(settingsPath)
+ ? JSON.parse(NodeFS.readFileSync(settingsPath, "utf8"))
+ : {};
+ settings.providerInstances = {
+ ...settings.providerInstances,
+ opencode: { driver: "opencode", enabled: true },
+ pi: { driver: "pi", enabled: true },
+ grok: { driver: "grok", enabled: true },
+ "mock-acp": {
+ driver: "acpRegistry",
+ displayName: "Mock ACP",
+ enabled: true,
+ environment: [
+ { name: "T3_ACP_PROMPT_DELAY_MS", value: String(state.busySeconds * 1000) },
+ { name: "T3_ACP_PROMPT_RESPONSE_TEXT", value: `${env.id} finished its busy turn.` },
+ ],
+ config: { source: "local", commandPath: NODE, commandArgs: [MOCK_ACP_AGENT] },
+ },
+ };
+ settings.defaultModelSelection ??= { instanceId: "mock-acp", model: "default" };
+ NodeFS.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
+}
+
+// ---------------------------------------------------------------------------
+// Fake CLIs and package managers (`__tool ...args`)
+
+async function runTool(envId: string, tool: string, args: string[]): Promise {
+ const state = requireState();
+ const env = requireEnv(state, envId);
+ const provider = PROVIDER_KEYS.find((key) => PROVIDERS[key].command === tool);
+ if (provider) return runProviderCli(state, env, provider, args);
+ const method = TOOL_METHOD[tool];
+ if (!method) fail(`update-lab: unknown fake tool ${tool}`);
+ if (args[0] === "--version" || args[0] === "-v") return void console.log("0.0.0-update-lab");
+
+ if (tool === "brew") {
+ const prefix = NodePath.join(homeDir(env.id), "homebrew");
+ if (args[0] === "--prefix") return void console.log(prefix);
+ const name = args.findLast((arg) => !arg.startsWith("-"));
+ const owner = PROVIDER_KEYS.find((key) => PROVIDERS[key].brew?.name === name);
+ if (!owner) fail(`Error: No available formula or cask with the name "${name}".`);
+ if (args[0] === "info") {
+ const latest = state.latest[owner];
+ return void console.log(
+ JSON.stringify(
+ PROVIDERS[owner].brew!.kind === "formula"
+ ? { formulae: [{ versions: { stable: latest } }], casks: [] }
+ : { formulae: [], casks: [{ version: `${latest},lab` }] },
+ ),
+ );
+ }
+ if (args[0] === "upgrade") return install(env.id, owner, tool, undefined);
+ fail(`update-lab brew: unsupported ${args.join(" ")}`);
+ }
+
+ // npm/pnpm/bun/yarn/volta/vp install commands name ``, `@latest` or `@x.y.z`.
+ for (const arg of args) {
+ for (const key of PROVIDER_KEYS) {
+ const { pkg } = PROVIDERS[key];
+ if (arg !== pkg && !arg.startsWith(`${pkg}@`)) continue;
+ const requested = arg.slice(pkg.length + 1);
+ if (method === "npm") {
+ const prefix = args[args.indexOf("--prefix") + 1];
+ if (
+ env.installs[key].method !== "npm" ||
+ prefix !== NodePath.join(homeDir(env.id), ".npm-global")
+ ) {
+ fail(`npm error ${pkg} is not installed under --prefix ${prefix ?? "(default)"}`);
+ }
+ }
+ return install(
+ env.id,
+ key,
+ tool,
+ requested && requested !== "latest" ? requested : undefined,
+ );
+ }
+ }
+ fail(`update-lab ${tool}: unsupported ${args.join(" ")}`);
+}
+
+async function runProviderCli(
+ state: LabState,
+ env: LabEnv,
+ provider: ProviderKey,
+ args: string[],
+): Promise {
+ const version = env.installs[provider].version;
+ const [first, second] = args;
+ if (first === "--version" || first === "-v") {
+ const banner = {
+ codex: `codex-cli ${version}`,
+ claude: `${version} (Claude Code)`,
+ opencode: version,
+ pi: version,
+ grok: `grok ${version}`,
+ }[provider];
+ return void console.log(banner);
+ }
+ const native =
+ (provider === "claude" && first === "update") ||
+ (provider === "codex" && first === "update") ||
+ (provider === "opencode" && first === "upgrade") ||
+ (provider === "grok" && first === "update") ||
+ (provider === "pi" && first === "update" && second === "--self");
+ if (native) return install(env.id, provider, PROVIDERS[provider].command, undefined);
+ if (provider === "grok" && first === "models") return void console.log("You are logged in");
+ if (provider === "codex" && first === "app-server") return codexAppServer(env, version);
+ // Session probes (claude stream-json, opencode serve, pi --mode rpc) are out of
+ // scope: a quiet failure keeps installed + version, so advisories still show.
+ process.stderr.write(`update-lab fake ${provider}: ${args.join(" ")} is not simulated\n`);
+ process.exit(1);
+}
+
+/** Just enough of `codex app-server` for the status probe to read the version. */
+async function codexAppServer(env: LabEnv, version: string): Promise {
+ const codexHome = process.env.CODEX_HOME || NodePath.join(homeDir(env.id), ".codex");
+ const results: Record = {
+ initialize: {
+ codexHome,
+ platformFamily: "unix",
+ platformOs: "linux",
+ userAgent: `codex_cli_rs/${version}`,
+ },
+ "account/read": { account: null, requiresOpenaiAuth: false },
+ "skills/list": { data: [] },
+ "model/list": { data: [] },
+ };
+ const lines = NodeReadline.createInterface({ input: process.stdin });
+ for await (const line of lines) {
+ let message: { id?: unknown; method?: string };
+ try {
+ message = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (message.id === undefined || message.method === undefined) continue;
+ const result = results[message.method];
+ process.stdout.write(
+ `${JSON.stringify(
+ result === undefined
+ ? { id: message.id, error: { code: -32601, message: `${message.method} not simulated` } }
+ : { id: message.id, result },
+ )}\n`,
+ );
+ }
+}
+
+/** Shared install path: progress output for `delayMs`, injected failure, then the new version. */
+async function install(
+ envId: string,
+ provider: ProviderKey,
+ tool: string,
+ requested: string | undefined,
+): Promise {
+ const state = requireState();
+ const env = requireEnv(state, envId);
+ const current = env.installs[provider];
+ const method = TOOL_METHOD[tool];
+ if (method && method !== current.method) {
+ fail(`${tool}: ${PROVIDERS[provider].pkg} is not managed by ${tool} here (${current.method}).`);
+ }
+ const target = requested ?? state.latest[provider];
+ const steps = 5;
+ for (let step = 1; step <= steps; step++) {
+ console.log(`${tool}: fetching ${PROVIDERS[provider].pkg}@${target} [${step}/${steps}]`);
+ await new Promise((resolve) => setTimeout(resolve, state.delayMs / steps));
+ }
+ if (requireState().envs.find((e) => e.id === envId)!.installs[provider].failing) {
+ process.stderr.write(`${tool}: injected failure installing ${PROVIDERS[provider].pkg}\n`);
+ process.exit(1);
+ }
+ updateState((next) => {
+ requireEnv(next, envId).installs[provider].version = target;
+ });
+ console.log(`${tool}: installed ${PROVIDERS[provider].pkg}@${target} (was ${current.version})`);
+}
+
+// ---------------------------------------------------------------------------
+// Fake npm registry + t3 release feed (`__feed `)
+
+function runFeed(port: number): void {
+ const server = NodeHttp.createServer((request, response) => {
+ const url = new URL(request.url ?? "/", "http://lab");
+ const send = (status: number, body: unknown) => {
+ response.writeHead(status, { "content-type": "application/json" });
+ response.end(JSON.stringify(body));
+ };
+ const state = readState();
+ if (!state) return send(503, { error: "no lab state" });
+
+ // GET /npm//latest and /npm/; the server URI-encodes scoped names.
+ const npm = /^\/npm\/(.+?)(\/latest)?$/.exec(url.pathname);
+ if (npm) {
+ const name = decodeURIComponent(npm[1]!);
+ const key = PROVIDER_KEYS.find((candidate) => PROVIDERS[candidate].pkg === name);
+ if (!key) return send(404, { error: "Not found" });
+ const version = state.latest[key];
+ return npm[2]
+ ? send(200, { name, version })
+ : send(200, {
+ name,
+ "dist-tags": { latest: version },
+ versions: { [version]: { name, version } },
+ });
+ }
+
+ // GET /releases/index.json?page=N: GitHub's list-releases shape, newest first.
+ if (url.pathname === "/releases/index.json") {
+ const page = Number(url.searchParams.get("page") ?? "1");
+ return send(
+ 200,
+ page === 1 ? publishedRuntimes().map((v) => ({ tag_name: `v${v}`, draft: false })) : [],
+ );
+ }
+
+ // GET /releases/v/: SHA256SUMS and archives.
+ const asset = /^\/releases\/(v[^/]+)\/([^/]+)$/.exec(url.pathname);
+ if (asset) {
+ const file = NodePath.join(RELEASES, asset[1]!, asset[2]!);
+ if (!NodeFS.existsSync(file)) return send(404, { error: "Not found" });
+ response.writeHead(200, { "content-length": NodeFS.statSync(file).size });
+ return void NodeFS.createReadStream(file).pipe(response);
+ }
+ send(404, { error: "Not found" });
+ });
+ server.listen(port, "127.0.0.1");
+}
+
+function publishedRuntimes(): string[] {
+ if (!NodeFS.existsSync(RELEASES)) return [];
+ return NodeFS.readdirSync(RELEASES)
+ .filter((dir) => NodeFS.existsSync(NodePath.join(RELEASES, dir, "SHA256SUMS")))
+ .map((dir) => dir.slice(1))
+ .sort((a, b) => compareVersions(b, a));
+}
+
+function compareVersions(a: string, b: string): number {
+ const parse = (v: string) => v.split(/[.-]/).map((part) => Number(part) || 0);
+ const [left, right] = [parse(a), parse(b)];
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
+ if (diff !== 0) return diff;
+ }
+ return 0;
+}
+
+// ---------------------------------------------------------------------------
+// Runtimes for the release feed and the service launcher
+
+/**
+ * Publish `t3--.tar.gz` + SHA256SUMS. Without `--archive`
+ * this is a source runtime: a copy of apps/server whose package.json carries
+ * `version` (every version check reads it) and whose node_modules links back
+ * to this checkout, started by a `t3` wrapper.
+ */
+function buildRuntime(version: string, archive: string | undefined): string {
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
+ fail(`Not an exact version: ${version}`);
+ const releaseDir = NodePath.join(RELEASES, `v${version}`);
+ const fileName = `t3-${version}-${PLATFORM_KEY}.tar.gz`;
+ const archivePath = NodePath.join(releaseDir, fileName);
+ NodeFS.mkdirSync(releaseDir, { recursive: true });
+ if (archive) {
+ NodeFS.copyFileSync(NodePath.resolve(archive), archivePath);
+ } else {
+ const stage = NodePath.join(LAB, "build", `t3-${version}-${PLATFORM_KEY}`);
+ NodeFS.rmSync(stage, { recursive: true, force: true });
+ const serverSource = NodePath.join(REPO, "apps/server");
+ NodeFS.cpSync(serverSource, NodePath.join(stage, "server"), {
+ recursive: true,
+ filter: (source) =>
+ !["node_modules", "dist", "dist-exe"].includes(NodePath.relative(serverSource, source)),
+ });
+ const packageJsonPath = NodePath.join(stage, "server/package.json");
+ const packageJson = JSON.parse(NodeFS.readFileSync(packageJsonPath, "utf8"));
+ packageJson.version = version;
+ NodeFS.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
+ NodeFS.symlinkSync(
+ NodePath.join(serverSource, "node_modules"),
+ NodePath.join(stage, "server/node_modules"),
+ );
+ writeExecutable(
+ NodePath.join(stage, "t3"),
+ `#!/bin/sh\nexec '${NODE}' "$(dirname "$0")/server/src/bin.ts" "$@"\n`,
+ );
+ NodeChildProcess.execFileSync("tar", [
+ "-czf",
+ archivePath,
+ "-C",
+ NodePath.dirname(stage),
+ NodePath.basename(stage),
+ ]);
+ NodeFS.rmSync(stage, { recursive: true, force: true });
+ }
+ const digest = NodeCrypto.createHash("sha256")
+ .update(NodeFS.readFileSync(archivePath))
+ .digest("hex");
+ NodeFS.writeFileSync(NodePath.join(releaseDir, "SHA256SUMS"), `${digest} ${fileName}\n`);
+ return archivePath;
+}
+
+/** Install a published runtime the way pinnedRuntime does, and make it the launcher's active version. */
+function installLauncherRuntime(envId: string, version: string): void {
+ const runtimeDir = NodePath.join(t3Home(envId), "runtime");
+ const versionDir = NodePath.join(runtimeDir, "versions", version);
+ if (!NodeFS.existsSync(NodePath.join(versionDir, ".install-complete"))) {
+ const archive = NodePath.join(RELEASES, `v${version}`, `t3-${version}-${PLATFORM_KEY}.tar.gz`);
+ if (!NodeFS.existsSync(archive)) buildRuntime(version, undefined);
+ NodeFS.mkdirSync(versionDir, { recursive: true });
+ NodeChildProcess.execFileSync("tar", [
+ "-xzf",
+ archive,
+ "-C",
+ versionDir,
+ "--strip-components=1",
+ ]);
+ NodeFS.writeFileSync(NodePath.join(versionDir, ".install-complete"), `${version}\n`);
+ }
+ const statePath = NodePath.join(runtimeDir, "service-state.json");
+ if (!NodeFS.existsSync(statePath)) {
+ NodeFS.writeFileSync(statePath, `${JSON.stringify({ protocol: 3, activeVersion: version })}\n`);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Processes
+
+function listeningPorts(): Set {
+ const output = NodeChildProcess.execFileSync("ss", ["-H", "-ltn"], { encoding: "utf8" });
+ return new Set(
+ output
+ .split("\n")
+ .map((line) => Number(line.trim().split(/\s+/)[3]?.split(":").pop()))
+ .filter((port) => Number.isInteger(port) && port > 0),
+ );
+}
+
+/** An OS-assigned port that `ss` does not already list and nobody else in the lab holds. */
+async function freePort(host: string, taken: Set): Promise {
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const port = await new Promise((resolve, reject) => {
+ const probe = NodeNet.createServer();
+ probe.once("error", reject);
+ probe.listen(0, host, () => {
+ const address = probe.address();
+ probe.close(() => resolve(typeof address === "object" && address ? address.port : 0));
+ });
+ });
+ if (port > 0 && !taken.has(port) && !listeningPorts().has(port)) {
+ taken.add(port);
+ return port;
+ }
+ }
+ fail("Could not find a free port.");
+}
+
+/** True only for a live process whose command line is still one the lab started. */
+function isLabProcess(pid: number | null, marker: string): pid is number {
+ if (!pid) return false;
+ try {
+ return NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(marker);
+ } catch {
+ return false;
+ }
+}
+
+const serverMarker = (env: LabEnv) => (env.launcher ? "__service-launcher" : t3Home(env.id));
+
+/** The runtime the service launcher runs now, which moves after a self-update. */
+function launcherLabel(env: LabEnv): string {
+ if (!env.launcher) return "";
+ const statePath = NodePath.join(t3Home(env.id), "runtime", "service-state.json");
+ const active = NodeFS.existsSync(statePath)
+ ? JSON.parse(NodeFS.readFileSync(statePath, "utf8")).activeVersion
+ : "?";
+ return ` launcher@${active}`;
+}
+
+/** The minimal environment a lab server sees; nothing from this shell leaks in. */
+function serverEnvironment(state: LabState, env: LabEnv): NodeJS.ProcessEnv {
+ const home = homeDir(env.id);
+ const keep = ["LANG", "LC_ALL", "TZ", "USER", "LOGNAME", "TMPDIR"];
+ return {
+ ...Object.fromEntries(
+ keep.flatMap((key) => (process.env[key] ? [[key, process.env[key]]] : [])),
+ ),
+ HOME: home,
+ PATH: envPath(env.id),
+ SHELL: "/bin/bash",
+ XDG_CONFIG_HOME: NodePath.join(home, ".config"),
+ XDG_DATA_HOME: NodePath.join(home, ".local/share"),
+ XDG_CACHE_HOME: NodePath.join(home, ".cache"),
+ XDG_STATE_HOME: NodePath.join(home, ".local/state"),
+ npm_config_registry: `http://127.0.0.1:${state.feedPort}/npm`,
+ T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${state.feedPort}/releases`,
+ T3CODE_UPDATE_LAB: "1",
+ T3CODE_NO_BROWSER: "1",
+ };
+}
+
+function spawnDetached(
+ command: string,
+ args: string[],
+ options: { env: NodeJS.ProcessEnv; cwd: string; log: string },
+): number {
+ const fd = NodeFS.openSync(options.log, "a");
+ const child = NodeChildProcess.spawn(command, args, {
+ cwd: options.cwd,
+ env: options.env,
+ detached: true,
+ stdio: ["ignore", fd, fd],
+ });
+ NodeFS.closeSync(fd);
+ child.unref();
+ if (!child.pid) fail(`Failed to start ${command} ${args.join(" ")}`);
+ return child.pid;
+}
+
+function startServer(state: LabState, env: LabEnv): number {
+ NodeFS.writeFileSync(logPath(env.id), "");
+ const environment = serverEnvironment(state, env);
+ if (env.launcher) {
+ installLauncherRuntime(env.id, state.fromVersion);
+ // The launcher runs `/t3 serve` and passes its environment through.
+ return spawnDetached(NODE, [SERVER_BIN, "__service-launcher"], {
+ env: {
+ ...environment,
+ T3CODE_HOME: t3Home(env.id),
+ T3CODE_PORT: String(env.port),
+ T3CODE_HOST: state.host,
+ },
+ cwd: projectDir(env.id),
+ log: logPath(env.id),
+ });
+ }
+ return spawnDetached(
+ NODE,
+ [
+ SERVER_BIN,
+ "serve",
+ "--base-dir",
+ t3Home(env.id),
+ "--port",
+ String(env.port),
+ "--host",
+ state.host,
+ projectDir(env.id),
+ ],
+ { env: environment, cwd: projectDir(env.id), log: logPath(env.id) },
+ );
+}
+
+async function waitForPairingUrl(env: LabEnv): Promise {
+ const deadline = Date.now() + 120_000;
+ while (Date.now() < deadline) {
+ const log = NodeFS.readFileSync(logPath(env.id), "utf8");
+ const url = /Pairing URL: (\S+)/.exec(log)?.[1];
+ if (url) return url;
+ if (!isLabProcess(env.pid, serverMarker(env))) {
+ fail(`${env.id} exited during startup; see ${logPath(env.id)}\n${log.slice(-2000)}`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ fail(`${env.id} did not print a pairing URL within 120s; see ${logPath(env.id)}`);
+}
+
+/** SIGTERM the process group we created, then SIGKILL it if it lingers. */
+async function stopGroup(pid: number, marker: string): Promise {
+ if (!isLabProcess(pid, marker)) return;
+ process.kill(-pid, "SIGTERM");
+ const deadline = Date.now() + 15_000;
+ while (Date.now() < deadline && isLabProcess(pid, marker)) {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ try {
+ process.kill(-pid, "SIGKILL");
+ } catch {
+ // The group is already gone.
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Talking to a lab server like a client does
+
+async function sessionToken(env: LabEnv): Promise {
+ const tokenPath = NodePath.join(envDir(env.id), "session-token");
+ if (NodeFS.existsSync(tokenPath)) return NodeFS.readFileSync(tokenPath, "utf8").trim();
+ const token = NodeChildProcess.execFileSync(
+ NODE,
+ [
+ SERVER_BIN,
+ "auth",
+ "session",
+ "issue",
+ "--base-dir",
+ t3Home(env.id),
+ "--label",
+ "update-lab",
+ "--token-only",
+ ],
+ { cwd: REPO, encoding: "utf8", env: { PATH: process.env.PATH, HOME: homeDir(env.id) } },
+ ).trim();
+ NodeFS.writeFileSync(tokenPath, token, { mode: 0o600 });
+ return token;
+}
+
+async function authHeaders(env: LabEnv): Promise> {
+ return {
+ Authorization: `Bearer ${await sessionToken(env)}`,
+ "Content-Type": "application/json",
+ "x-t3-orchestration-protocol": ORCHESTRATION_PROTOCOL,
+ };
+}
+
+/** One unary call over the server's Effect RPC WebSocket protocol (JSON frames). */
+async function rpc(state: LabState, env: LabEnv, tag: string, payload: unknown): Promise {
+ const origin = originOf(state, env);
+ const ticketResponse = await fetch(`${origin}/api/auth/websocket-ticket`, {
+ method: "POST",
+ headers: await authHeaders(env),
+ });
+ if (!ticketResponse.ok) fail(`${env.id}: websocket ticket failed (${ticketResponse.status})`);
+ const { ticket } = (await ticketResponse.json()) as { ticket: string };
+ const socket = new WebSocket(
+ `${origin.replace("http", "ws")}/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL}&wsTicket=${encodeURIComponent(ticket)}`,
+ );
+ return await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`${tag} timed out`)), 10 * 60_000);
+ const finish = (settle: () => void) => {
+ clearTimeout(timer);
+ socket.close();
+ settle();
+ };
+ socket.addEventListener("open", () =>
+ socket.send(JSON.stringify({ _tag: "Request", id: "1", tag, payload, headers: [] })),
+ );
+ socket.addEventListener("error", () => finish(() => reject(new Error(`${tag}: socket error`))));
+ socket.addEventListener("message", (event) => {
+ const decoded = JSON.parse(String(event.data));
+ for (const message of Array.isArray(decoded) ? decoded : [decoded]) {
+ if (message._tag !== "Exit" || message.requestId !== "1") continue;
+ const exit = message.exit;
+ finish(() =>
+ exit._tag === "Success"
+ ? resolve(exit.value)
+ : reject(new Error(`${tag} failed: ${JSON.stringify(exit.cause)}`)),
+ );
+ }
+ });
+ });
+}
+
+function printProviders(providers: ReadonlyArray): void {
+ for (const provider of providers) {
+ const advisory = provider.versionAdvisory ?? {};
+ const update = provider.updateState ?? null;
+ console.log(
+ [
+ ` ${provider.instanceId.padEnd(10)} ${String(provider.status).padEnd(8)}`,
+ `installed=${provider.version ?? "-"} latest=${advisory.latestVersion ?? "-"} advisory=${advisory.status ?? "-"}`,
+ `update=${advisory.canUpdate ? JSON.stringify(advisory.updateCommand) : "manual"}`,
+ update ? `last=${update.status}: ${update.message ?? ""}` : "",
+ ].join(" "),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Commands
+
+function option(args: string[], name: string): string | undefined {
+ const index = args.indexOf(`--${name}`);
+ return index >= 0 ? args[index + 1] : undefined;
+}
+
+async function up(args: string[]): Promise {
+ const previous = readState();
+ const host = option(args, "host") ?? previous?.host ?? "127.0.0.1";
+ const envCount = Number(option(args, "envs") ?? previous?.envs.length ?? 3);
+ const launcherCount = Number(option(args, "launcher") ?? 1);
+ const [major, minor, patch] = REPO_VERSION.split(/[.-]/).map(Number);
+ const state: LabState = previous ?? {
+ host,
+ feedPort: 0,
+ feedPid: null,
+ delayMs: 3000,
+ busySeconds: 120,
+ fromVersion: `${major}.${minor}.${Math.max(0, patch! - 1)}`,
+ latest: Object.fromEntries(
+ PROVIDER_KEYS.map((key) => [key, PROVIDERS[key].latest]),
+ ) as LabState["latest"],
+ envs: [],
+ };
+ state.host = host;
+ state.busySeconds = Number(option(args, "busy-seconds") ?? state.busySeconds);
+ state.fromVersion = option(args, "from-version") ?? state.fromVersion;
+ NodeFS.mkdirSync(LAB, { recursive: true });
+ const taken = new Set(state.envs.map((env) => env.port).concat(state.feedPort));
+
+ if (!isLabProcess(state.feedPid, "__feed")) {
+ state.feedPort = await freePort("127.0.0.1", taken);
+ state.feedPid = spawnDetached(NODE, [SELF, "__feed", String(state.feedPort)], {
+ env: { PATH: process.env.PATH },
+ cwd: REPO,
+ log: NodePath.join(LAB, "feed.log"),
+ });
+ }
+ // The feed always offers this checkout's version, the update a launcher env is behind on.
+ if (!publishedRuntimes().includes(REPO_VERSION)) buildRuntime(REPO_VERSION, undefined);
+
+ for (let index = state.envs.length; index < envCount; index++) {
+ const row = INSTALL_MATRIX[index % INSTALL_MATRIX.length]!;
+ state.envs.push({
+ id: `env${index + 1}`,
+ port: 0,
+ pid: null,
+ launcher: index >= envCount - launcherCount,
+ projectId: null,
+ installs: Object.fromEntries(
+ PROVIDER_KEYS.map((key) => [
+ key,
+ { method: row[key], version: PROVIDERS[key].installed, failing: false },
+ ]),
+ ) as LabEnv["installs"],
+ });
+ }
+ writeState(state);
+
+ const started: LabEnv[] = [];
+ for (const env of state.envs) {
+ if (isLabProcess(env.pid, serverMarker(env))) continue;
+ seedEnvFiles(state, env);
+ if (!env.port || listeningPorts().has(env.port)) env.port = await freePort(host, taken);
+ env.pid = startServer(state, env);
+ started.push(env);
+ writeState(state);
+ }
+ console.log(
+ `feed http://127.0.0.1:${state.feedPort} (npm registry /npm, releases /releases)`,
+ );
+ const urls = await Promise.all(started.map(waitForPairingUrl));
+ for (const env of state.envs) {
+ const index = started.indexOf(env);
+ console.log(`${env.id.padEnd(9)} ${originOf(state, env)} pid ${env.pid}${launcherLabel(env)}`);
+ console.log(` ${index >= 0 ? urls[index] : "(already running; `pair` mints a URL)"}`);
+ }
+}
+
+async function down(): Promise {
+ const state = readState();
+ if (!state) return;
+ for (const env of state.envs) {
+ if (env.pid) await stopGroup(env.pid, serverMarker(env));
+ env.pid = null;
+ }
+ if (state.feedPid) await stopGroup(state.feedPid, "__feed");
+ state.feedPid = null;
+ writeState(state);
+ console.log("Lab stopped.");
+}
+
+function status(): void {
+ const state = requireState();
+ const feed = isLabProcess(state.feedPid, "__feed") ? "up" : "down";
+ console.log(
+ `feed http://127.0.0.1:${state.feedPort} ${feed}; delay ${state.delayMs}ms; runtimes ${publishedRuntimes().join(", ") || "-"}`,
+ );
+ for (const env of state.envs) {
+ const alive = isLabProcess(env.pid, serverMarker(env));
+ console.log(
+ `${env.id} ${originOf(state, env)} pid ${env.pid ?? "-"} ${alive ? "up" : "down"}${launcherLabel(env)}`,
+ );
+ for (const key of PROVIDER_KEYS) {
+ const install = env.installs[key];
+ const behind = compareVersions(install.version, state.latest[key]) < 0 ? "behind" : "current";
+ console.log(
+ ` ${key.padEnd(9)} ${install.method.padEnd(9)} installed ${install.version.padEnd(8)} latest ${state.latest[key].padEnd(8)} ${behind}${install.failing ? " FAILING" : ""}`,
+ );
+ }
+ }
+}
+
+async function busy(state: LabState, env: LabEnv): Promise {
+ const headers = await authHeaders(env);
+ if (!env.projectId) {
+ const projectId = NodeCrypto.randomUUID();
+ const created = await fetch(`${originOf(state, env)}/api/projects/mutate`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ type: "project.create",
+ commandId: NodeCrypto.randomUUID(),
+ projectId,
+ title: `${env.id} lab project`,
+ workspaceRoot: projectDir(env.id),
+ }),
+ });
+ if (!created.ok) fail(`Project creation failed: ${created.status} ${await created.text()}`);
+ updateState((next) => {
+ requireEnv(next, env.id).projectId = projectId;
+ });
+ env.projectId = projectId;
+ }
+ const threadId = NodeCrypto.randomUUID();
+ await rpc(state, env, "orchestration.launchThread", {
+ commandId: NodeCrypto.randomUUID(),
+ threadId,
+ projectId: env.projectId,
+ title: `Busy for ${state.busySeconds}s`,
+ modelSelection: { instanceId: "mock-acp", model: "default" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ workspaceStrategy: { type: "root" },
+ initialMessage: { text: "Stay busy so updates have to wait.", attachments: [] },
+ });
+ console.log(`${env.id}: thread ${threadId} runs for ~${state.busySeconds}s on mock-acp.`);
+}
+
+async function main(argv: string[]): Promise {
+ const [command, ...args] = argv;
+ switch (command) {
+ case "__tool":
+ return runTool(args[0]!, args[1]!, args.slice(2));
+ case "__feed":
+ return runFeed(Number(args[0]));
+ case "up":
+ return up(args);
+ case "down":
+ return down();
+ case "status":
+ return status();
+ case "pair": {
+ const state = requireState();
+ const env = requireEnv(state, args[0]);
+ const output = NodeChildProcess.execFileSync(
+ NODE,
+ [
+ SERVER_BIN,
+ "auth",
+ "pairing",
+ "create",
+ "--base-dir",
+ t3Home(env.id),
+ "--base-url",
+ originOf(state, env),
+ ],
+ { cwd: REPO, encoding: "utf8", env: { PATH: process.env.PATH, HOME: homeDir(env.id) } },
+ );
+ return void console.log(/(http\S+\/pair#token=\S+)/.exec(output)?.[1] ?? output);
+ }
+ case "publish": {
+ const provider = requireProvider(args[0]);
+ if (!args[1]) fail("Usage: publish ");
+ updateState((state) => {
+ state.latest[provider] = args[1]!;
+ });
+ return void console.log(`${PROVIDERS[provider].pkg} latest is now ${args[1]}.`);
+ }
+ case "fail": {
+ const provider = requireProvider(args[1]);
+ if (args[2] !== "on" && args[2] !== "off") fail("Usage: fail on|off");
+ updateState((state) => {
+ requireEnv(state, args[0]).installs[provider].failing = args[2] === "on";
+ });
+ return void console.log(
+ `${args[0]} ${provider} installs will ${args[2] === "on" ? "fail" : "succeed"}.`,
+ );
+ }
+ case "delay": {
+ const ms = Number(args[0]);
+ if (!Number.isInteger(ms) || ms < 0) fail("Usage: delay ");
+ updateState((state) => {
+ state.delayMs = ms;
+ });
+ return void console.log(`Installers now take ${ms}ms.`);
+ }
+ case "providers": {
+ const state = requireState();
+ const env = requireEnv(state, args[0]);
+ const result = await rpc(state, env, "server.getConfig", {});
+ console.log(`${env.id} ${originOf(state, env)}`);
+ return printProviders(result.providers);
+ }
+ case "update": {
+ const state = requireState();
+ const env = requireEnv(state, args[0]);
+ const instanceId = args[1] ?? fail("Usage: update [version]");
+ const driver =
+ PROVIDER_KEYS.map((key) => PROVIDERS[key].driver).find((d) => d === instanceId) ??
+ instanceId;
+ const result = await rpc(state, env, "server.updateProvider", {
+ provider: driver,
+ instanceId,
+ ...(args[2] ? { targetVersion: args[2] } : {}),
+ });
+ return printProviders(result.providers.filter((p: any) => p.instanceId === instanceId));
+ }
+ case "busy": {
+ const state = requireState();
+ return busy(state, requireEnv(state, args[0]));
+ }
+ case "self-update": {
+ const state = requireState();
+ const env = requireEnv(state, args[0]);
+ if (!args[1]) fail("Usage: self-update ");
+ const result = await rpc(state, env, "server.updateServer", { targetVersion: args[1] });
+ return void console.log(JSON.stringify(result));
+ }
+ case "build-runtime": {
+ if (!args[0]) fail("Usage: build-runtime [--archive file]");
+ return void console.log(`Published ${buildRuntime(args[0], option(args, "archive"))}`);
+ }
+ case "reset": {
+ const state = readState();
+ if (state && [state.feedPid, ...state.envs.map((e) => e.pid)].some((pid) => pid !== null)) {
+ fail("Run `down` first.");
+ }
+ NodeFS.rmSync(LAB, { recursive: true, force: true });
+ return void console.log(`Removed ${LAB}.`);
+ }
+ default:
+ console.log(HELP);
+ if (command && command !== "--help" && command !== "help") process.exit(1);
+ }
+}
+
+await main(process.argv.slice(2));
From 25b6b08e4b4c9b126551f336df879f27c27625b4 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:06:27 +0000
Subject: [PATCH 002/149] feat(server): update provider CLIs in the background
when nothing is running
Adds an update window that stays closed while threads run or wait for input,
for 15 minutes after any client interaction, and while a scheduled task or
usage-limit resume is due within 10 minutes. A new Update automatically
setting (on by default) installs provider CLI updates inside that window.
The provider maintenance runner is now one server-lifetime instance, so RPC
and background updates share the per-installer lock. It was previously built
per WebSocket connection, so two clients' updates never queued.
---
.../SettingsServerControlsRouteScreen.tsx | 19 ++
.../server/src/background/BackgroundPolicy.ts | 8 +
.../src/environment/ServerEnvironment.ts | 1 +
.../src/mcp/toolkits/environment/handlers.ts | 2 +
.../src/mcp/toolkits/environment/tools.ts | 2 +
.../src/orchestration-v2/ProjectionStore.ts | 36 +++
apps/server/src/server.ts | 11 +
.../server/src/updates/ProviderAutoUpdater.ts | 100 ++++++++
apps/server/src/updates/UpdateWindow.ts | 232 ++++++++++++++++++
apps/server/src/ws.ts | 9 +-
.../components/settings/SettingsPanels.tsx | 42 ++++
.../src/components/settings/settingsSearch.ts | 7 +
docs/user/updating.md | 6 +
packages/contracts/src/environment.ts | 2 +
packages/contracts/src/settings.ts | 3 +
15 files changed, 479 insertions(+), 1 deletion(-)
create mode 100644 apps/server/src/updates/ProviderAutoUpdater.ts
create mode 100644 apps/server/src/updates/UpdateWindow.ts
diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
index ae4f43b5fb376..964f2a3f8c888 100644
--- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
@@ -230,6 +230,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
(target) =>
target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true,
);
+ const supportsAutomaticUpdates = targets.every(
+ (target) => target.environment.serverConfig.environment.capabilities.automaticUpdates === true,
+ );
const disabledFor = (key: string) =>
disabled ||
(projectSelected &&
@@ -462,6 +465,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
disabled={disabledFor("enableProviderUpdateChecks")}
onValueChange={(value) => write({ enableProviderUpdateChecks: value })}
/>
+
+ write({ automaticUpdates: value })}
+ />
+
Effect.Effect;
readonly reportHostPowerState: (snapshot: HostPowerSnapshot) => Effect.Effect;
readonly snapshot: Effect.Effect;
+ /** When any client last reported recent interaction, so updates can wait for quiet. */
+ readonly lastClientInteractionAt: Effect.Effect>;
readonly streamChanges: Stream.Stream;
readonly subscribe: Effect.Effect<
{
@@ -213,6 +216,7 @@ export const make = Effect.fn("background.policy.make")(function* () {
const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const leasesRef = yield* Ref.make(new Map());
+ const lastClientInteractionAtRef = yield* Ref.make(Option.none());
const changes = yield* PubSub.sliding(1);
const publishMutex = yield* Semaphore.make(1);
@@ -266,6 +270,9 @@ export const make = Effect.fn("background.policy.make")(function* () {
expiresAt,
};
yield* Ref.update(leasesRef, (leases) => upsertClientActivityLease(leases, lease, now));
+ if (input.recentlyInteracted) {
+ yield* Ref.set(lastClientInteractionAtRef, Option.some(now));
+ }
yield* publishSnapshotUnlocked;
}),
);
@@ -339,6 +346,7 @@ export const make = Effect.fn("background.policy.make")(function* () {
removeRpcClient,
reportHostPowerState: hostPowerMonitor.report,
snapshot,
+ lastClientInteractionAt: Ref.get(lastClientInteractionAtRef),
streamChanges: Stream.fromPubSub(changes),
subscribe: subscribeBeforeSnapshot(changes, snapshot, publishMutex),
hasDemand,
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index 6381547468ae3..aa1373c0da73d 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -231,6 +231,7 @@ export const make = Effect.gen(function* () {
projectWorktreeCleanup: true,
worktreesDirectory: true,
threadRestartContinuation: true,
+ automaticUpdates: true,
projectSettingsOverrides: true,
threadSnooze: true,
environmentThemes: true,
diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts
index d7b84ca0599c4..dc51226bcf227 100644
--- a/apps/server/src/mcp/toolkits/environment/handlers.ts
+++ b/apps/server/src/mcp/toolkits/environment/handlers.ts
@@ -13,6 +13,7 @@ export function preferences(settings: ServerSettings) {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
enableProviderUpdateChecks,
+ automaticUpdates,
backgroundActivity,
sourceControlWritingStyle,
} = settings;
@@ -21,6 +22,7 @@ export function preferences(settings: ServerSettings) {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
enableProviderUpdateChecks,
+ automaticUpdates,
backgroundActivity: { profile: backgroundActivity.profile },
sourceControlWritingStyle: {
...sourceControlWritingStyle,
diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts
index e441174900ce1..88e103ce0debe 100644
--- a/apps/server/src/mcp/toolkits/environment/tools.ts
+++ b/apps/server/src/mcp/toolkits/environment/tools.ts
@@ -18,6 +18,7 @@ const PreferenceFields = {
defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin,
enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks,
+ automaticUpdates: ServerSettings.fields.automaticUpdates,
backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }),
sourceControlWritingStyle: Schema.Struct({
mode: Schema.String,
@@ -59,6 +60,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update"
defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin,
enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks,
+ automaticUpdates: ServerSettingsPatch.fields.automaticUpdates,
backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })),
sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle,
}),
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index a6a092ff84b7a..8119dba91fc95 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -147,6 +147,9 @@ export type ProjectionStoreV2Error = typeof ProjectionStoreV2Error.Type;
export type ProjectionRecoveryKind =
| "queued-runs"
| "runtime"
+ // Work a restart or CLI replacement would cut off. Idle provider sessions
+ // do not count: they resume on the next turn.
+ | "active-work"
| "subagent-results"
| "delegated-completions";
@@ -564,6 +567,19 @@ function needsRecovery(
)
);
}
+ case "active-work":
+ return (
+ projection.runs.some(
+ (run) =>
+ ["preparing", "starting", "running", "waiting"].includes(run.status) ||
+ (run.status === "queued" && run.queueHeld !== true),
+ ) ||
+ projection.runtimeRequests.some((request) => request.status === "pending") ||
+ projection.providerThreads.some(
+ (thread) =>
+ thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0,
+ )
+ );
case "runtime":
return (
projection.runs.some(
@@ -3592,6 +3608,26 @@ export const layer: Layer.Layer =
)
ELSE 0 END
`;
+ case "active-work":
+ return sql`
+ SELECT thread_id FROM orchestration_v2_projection_runs
+ WHERE status IN ('preparing', 'starting', 'running', 'waiting')
+ UNION
+ SELECT thread_id FROM orchestration_v2_projection_runs
+ WHERE status = 'queued'
+ AND CASE WHEN json_valid(payload_json)
+ THEN json_extract(payload_json, '$.queueHeld') IS NOT 1
+ ELSE 1 END
+ UNION
+ SELECT thread_id FROM orchestration_v2_projection_runtime_requests
+ WHERE status = 'pending'
+ UNION
+ SELECT thread_id FROM orchestration_v2_projection_provider_threads
+ WHERE status = 'active'
+ OR CASE WHEN json_valid(payload_json)
+ THEN json_array_length(payload_json, '$.pendingBackgroundTasks') > 0
+ ELSE 0 END
+ `;
case "runtime":
return sql`
WITH pending_provider_threads AS MATERIALIZED (
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 6a698c2e46ab1..c4be403306d84 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -157,6 +157,9 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
import * as CursorUsageReader from "./usage/cursorUsageReader.ts";
import * as UsageService from "./usage/UsageService.ts";
import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";
+import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
+import * as ProviderAutoUpdater from "./updates/ProviderAutoUpdater.ts";
+import * as UpdateWindow from "./updates/UpdateWindow.ts";
import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";
import * as ResourceCleanupService from "./orchestration-v2/ResourceCleanupService.ts";
@@ -528,7 +531,15 @@ const layerProviderInstallationRefresh = Layer.effectDiscard(
}),
);
+// One maintenance runner for RPCs and background updates, so both queue
+// behind the same per-installer lock.
+const layerProviderUpdates = ProviderAutoUpdater.layer.pipe(
+ Layer.provide(UpdateWindow.layer.pipe(Layer.provide(ProjectionStoreV2.layer))),
+ Layer.provideMerge(ProviderMaintenanceRunner.layer),
+);
+
const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
+ layerProviderUpdates,
AgentAwarenessRelay.layer,
// Asks T3 Connect to deliver webhooks it held while this environment was offline.
HeldHooksWaker.layer,
diff --git a/apps/server/src/updates/ProviderAutoUpdater.ts b/apps/server/src/updates/ProviderAutoUpdater.ts
new file mode 100644
index 0000000000000..2c0c38a54b670
--- /dev/null
+++ b/apps/server/src/updates/ProviderAutoUpdater.ts
@@ -0,0 +1,100 @@
+import type { ServerProvider } from "@t3tools/contracts";
+import * as Clock from "effect/Clock";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schedule from "effect/Schedule";
+
+import * as ServerConfig from "../config.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as ProviderMaintenanceRunner from "../provider/providerMaintenanceRunner.ts";
+import { forkParked } from "../serverActivation.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import * as UpdateWindow from "./UpdateWindow.ts";
+
+/** A failed install is retried for the same version only after this long. */
+const RETRY_AFTER = Duration.hours(6);
+
+function isAutoUpdatable(provider: ServerProvider): boolean {
+ const advisory = provider.versionAdvisory;
+ const updateStatus = provider.updateState?.status;
+ return (
+ provider.enabled &&
+ provider.installed &&
+ advisory?.status === "behind_latest" &&
+ advisory.canUpdate &&
+ advisory.latestVersion !== null &&
+ updateStatus !== "queued" &&
+ updateStatus !== "running"
+ );
+}
+
+/**
+ * Installs provider CLI updates in the background when `automaticUpdates` is
+ * on, one instance at a time, only while the update window is open.
+ */
+export const layer = Layer.effectDiscard(
+ Effect.gen(function* () {
+ // A dev server shares the host's CLIs with the real install; leave them to it.
+ if ((yield* ServerConfig.ServerConfig).devUrl !== undefined) return;
+ const providers = yield* ProviderRegistry.ProviderRegistry;
+ const runner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
+ const updateWindow = yield* UpdateWindow.UpdateWindow;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const timings = yield* UpdateWindow.updateTimings;
+ // Keyed by instance and target version, so a newer release retries at once.
+ const attempts = new Map();
+
+ const pass = Effect.gen(function* () {
+ const settings = yield* serverSettings.getSettings;
+ if (!settings.automaticUpdates || !settings.enableProviderUpdateChecks) return;
+ const nowMs = yield* Clock.currentTimeMillis;
+ const candidates = (yield* providers.getProviders).filter((provider) => {
+ if (!isAutoUpdatable(provider)) return false;
+ const attemptedAt = attempts.get(
+ `${provider.instanceId}@${provider.versionAdvisory?.latestVersion}`,
+ );
+ return attemptedAt === undefined || nowMs - attemptedAt >= Duration.toMillis(RETRY_AFTER);
+ });
+ for (const provider of candidates) {
+ const targetVersion = provider.versionAdvisory?.latestVersion;
+ const install = Effect.gen(function* () {
+ attempts.set(`${provider.instanceId}@${targetVersion}`, yield* Clock.currentTimeMillis);
+ yield* Effect.logInfo("Updating provider in the background", {
+ instanceId: provider.instanceId,
+ fromVersion: provider.version,
+ targetVersion,
+ });
+ yield* runner
+ .updateProvider({ provider: provider.driver, instanceId: provider.instanceId })
+ .pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Background provider update failed", {
+ instanceId: provider.instanceId,
+ cause,
+ }),
+ ),
+ );
+ });
+ // Rechecked per install: the previous one may have run for minutes.
+ const installed = yield* updateWindow.runIfOpen({ closesWindows: false }, install);
+ if (Option.isNone(installed)) {
+ yield* Effect.logDebug("Provider auto-update waiting for the update window");
+ return;
+ }
+ }
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Provider auto-update pass failed", { cause }),
+ ),
+ Effect.withSpan("updates.ProviderAutoUpdater.pass"),
+ );
+
+ yield* forkParked(
+ Effect.sleep(timings.firstPassDelay).pipe(
+ Effect.andThen(pass.pipe(Effect.repeat(Schedule.spaced(timings.passInterval)))),
+ ),
+ );
+ }),
+);
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
new file mode 100644
index 0000000000000..be9e91055e840
--- /dev/null
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -0,0 +1,232 @@
+import type { ScheduledTaskId, ThreadId } from "@t3tools/contracts";
+import * as Config from "effect/Config";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Semaphore from "effect/Semaphore";
+
+import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
+import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
+import * as ServerSettings from "../serverSettings.ts";
+
+/** Work due this soon keeps the window closed, so it does not start late. */
+const UPDATE_LOOKAHEAD = Duration.minutes(5);
+/**
+ * Tasks repeating at least this often always have a run due soon. Missed runs
+ * catch up after a restart, so they only block while running.
+ */
+const FREQUENT_TASK_INTERVAL = Duration.minutes(15);
+
+/**
+ * Background update pacing. `T3CODE_UPDATE_LAB=1` (scripts/update-lab.ts)
+ * shortens the waits so update flows can be exercised in seconds.
+ */
+export const updateTimings = Config.Boolean("T3CODE_UPDATE_LAB").pipe(
+ Config.withDefault(false),
+ Config.map((lab) => ({
+ /**
+ * How long every client must be untouched before an update may interrupt
+ * it. Clients report activity about every 25s, so this must stay well above that.
+ */
+ quietPeriod: lab ? Duration.seconds(60) : Duration.minutes(15),
+ /** How often a waiting update rechecks the window or looks for new versions. */
+ recheck: lab ? Duration.seconds(5) : Duration.seconds(30),
+ firstPassDelay: lab ? Duration.seconds(10) : Duration.minutes(2),
+ passInterval: lab ? Duration.seconds(20) : Duration.minutes(10),
+ })),
+);
+
+export type UpdateWindowBlocker =
+ | { readonly type: "active-threads"; readonly threadIds: ReadonlyArray }
+ | { readonly type: "recent-interaction"; readonly quietAt: DateTime.Utc }
+ | { readonly type: "focused-window" }
+ | { readonly type: "provider-update" }
+ | {
+ readonly type: "scheduled-task";
+ readonly taskId: ScheduledTaskId;
+ readonly title: string;
+ /** Null while the task is running. */
+ readonly runsAt: DateTime.Utc | null;
+ }
+ | {
+ readonly type: "usage-limit-resume";
+ readonly threadId: ThreadId;
+ readonly resumesAt: DateTime.Utc;
+ };
+
+export interface UpdateWindowOptions {
+ /** The update closes the app windows (a desktop install), not just a reconnect. */
+ readonly closesWindows: boolean;
+}
+
+export interface UpdateWindowStatus {
+ readonly open: boolean;
+ readonly blockers: ReadonlyArray;
+}
+
+/**
+ * Decides when a background update may restart the server, replace a provider
+ * CLI, or relaunch the desktop app without cutting off someone's work.
+ */
+export class UpdateWindow extends Context.Service<
+ UpdateWindow,
+ {
+ readonly check: (options: UpdateWindowOptions) => Effect.Effect;
+ /**
+ * Runs `install` if the window is open, holding a lock every background
+ * install shares, so a server restart never cuts off a CLI install.
+ */
+ readonly runIfOpen: (
+ options: UpdateWindowOptions,
+ install: Effect.Effect,
+ ) => Effect.Effect, E, R>;
+ /** Waits for the window, then runs `install` like `runIfOpen`. */
+ readonly runWhenOpen: (
+ options: UpdateWindowOptions,
+ install: Effect.Effect,
+ ) => Effect.Effect;
+ }
+>()("t3/updates/UpdateWindow") {}
+
+export const make = Effect.fn("updates.UpdateWindow.make")(function* () {
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
+ const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const providers = yield* ProviderRegistry.ProviderRegistry;
+ const timings = yield* updateTimings;
+
+ const check: UpdateWindow["Service"]["check"] = Effect.fn("updates.UpdateWindow.check")(
+ function* (options) {
+ const now = yield* DateTime.now;
+ const horizon = DateTime.addDuration(now, UPDATE_LOOKAHEAD);
+ const blockers: Array = [];
+
+ // A failed read keeps the window closed: guessing "idle" could cut off a turn.
+ const activeThreadIds = yield* projections.getRecoveryThreadIds("active-work").pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read threads", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(activeThreadIds)) {
+ return { open: false, blockers: [{ type: "active-threads", threadIds: [] }] };
+ }
+ if (activeThreadIds.value.length > 0) {
+ blockers.push({ type: "active-threads", threadIds: activeThreadIds.value });
+ }
+
+ // Includes installs someone started by hand.
+ const providerUpdating = (yield* providers.getProviders).some(
+ (provider) =>
+ provider.updateState?.status === "queued" || provider.updateState?.status === "running",
+ );
+ if (providerUpdating) {
+ blockers.push({ type: "provider-update" });
+ }
+
+ const lastInteraction = yield* backgroundPolicy.lastClientInteractionAt;
+ if (Option.isSome(lastInteraction)) {
+ const quietAt = DateTime.addDuration(lastInteraction.value, timings.quietPeriod);
+ if (DateTime.isGreaterThan(quietAt, now)) {
+ blockers.push({ type: "recent-interaction", quietAt });
+ }
+ }
+
+ if (options.closesWindows) {
+ const policy = yield* backgroundPolicy.snapshot;
+ const hostIdleSeconds = policy.hostPower.stale ? null : policy.hostPower.idleSeconds;
+ const hostAway =
+ hostIdleSeconds !== null && hostIdleSeconds >= Duration.toSeconds(timings.quietPeriod);
+ const windowFocused = policy.leases.some(
+ (lease) => lease.clientKind === "desktop-renderer" && lease.focused,
+ );
+ if (windowFocused && !hostAway) {
+ blockers.push({ type: "focused-window" });
+ }
+ }
+
+ const tasks = yield* scheduledTasks.list().pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read scheduled tasks", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(tasks)) {
+ return { open: false, blockers };
+ }
+ for (const task of tasks.value.tasks) {
+ if (task.lastRunStatus === "running") {
+ blockers.push({
+ type: "scheduled-task",
+ taskId: task.id,
+ title: task.title,
+ runsAt: null,
+ });
+ continue;
+ }
+ if (!task.enabled || task.nextRunAt === null) continue;
+ if (
+ task.schedule.type === "interval" &&
+ task.schedule.everyMs < Duration.toMillis(FREQUENT_TASK_INTERVAL)
+ ) {
+ continue;
+ }
+ const runsAt = DateTime.make(task.nextRunAt);
+ if (Option.isSome(runsAt) && !DateTime.isGreaterThan(runsAt.value, horizon)) {
+ blockers.push({
+ type: "scheduled-task",
+ taskId: task.id,
+ title: task.title,
+ runsAt: runsAt.value,
+ });
+ }
+ }
+
+ const settings = yield* serverSettings.getSettings.pipe(Effect.option);
+ if (Option.isSome(settings) && settings.value.autoResumeLimitedThreads) {
+ const limited = yield* projections
+ .getLimitRecoveryCandidates({ now: horizon, autoResume: true, snooze: false })
+ .pipe(Effect.orElseSucceed(() => []));
+ for (const thread of limited) {
+ if (thread.limitRecovery?.autoResume !== true) continue;
+ const resumesAt = DateTime.make(thread.limitRecovery.resetAt);
+ if (Option.isSome(resumesAt)) {
+ blockers.push({
+ type: "usage-limit-resume",
+ threadId: thread.id,
+ resumesAt: resumesAt.value,
+ });
+ }
+ }
+ }
+
+ return { open: blockers.length === 0, blockers };
+ },
+ );
+
+ const installLock = yield* Semaphore.make(1);
+ const runIfOpen: UpdateWindow["Service"]["runIfOpen"] = (options, install) =>
+ installLock.withPermits(1)(
+ check(options).pipe(
+ Effect.flatMap((status) => (status.open ? Effect.asSome(install) : Effect.succeedNone)),
+ ),
+ );
+ const runWhenOpen: UpdateWindow["Service"]["runWhenOpen"] = (options, install) =>
+ Effect.gen(function* () {
+ while (true) {
+ const result = yield* runIfOpen(options, install);
+ if (Option.isSome(result)) return result.value;
+ yield* Effect.sleep(timings.recheck);
+ }
+ });
+
+ return UpdateWindow.of({ check, runIfOpen, runWhenOpen });
+});
+
+export const layer = Layer.effect(UpdateWindow, make());
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 1b045f7e963f1..a4cc87f2e1c1f 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -3131,6 +3131,8 @@ export const layer = Layer.unwrap(
const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const serverBrowser = yield* ServerBrowser.ServerBrowser;
const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
+ // Shared with background updates so every caller queues behind one lock per installer.
+ const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
const pullRequests = yield* PullRequestService.PullRequestService;
const sql = yield* SqlClient.SqlClient;
return HttpRouter.add(
@@ -3193,7 +3195,12 @@ export const layer = Layer.unwrap(
Layer.provide(DefectReporter.layer),
Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),
Layer.provide(AgentSessionScanner.layer),
- Layer.provide(ProviderMaintenanceRunner.layer),
+ Layer.provide(
+ Layer.succeed(
+ ProviderMaintenanceRunner.ProviderMaintenanceRunner,
+ providerMaintenanceRunner,
+ ),
+ ),
Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
// One server-lifetime service means clients share the same PR caches, and a WS
// mutation invalidates the HTTP diff cache that every client reads from.
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index bee57d21b2531..f5b07cfc8beee 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -629,6 +629,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
? ["Provider update checks"]
: []),
+ ...(settings.automaticUpdates !== DEFAULT_UNIFIED_SETTINGS.automaticUpdates
+ ? ["Update automatically"]
+ : []),
...(settings.continueThreadsAfterServerUpdate !==
DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate
? ["Continue threads after restarts"]
@@ -705,6 +708,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.responseStreamingMode,
settings.persistComposerContextStrip,
settings.enableProviderUpdateChecks,
+ settings.automaticUpdates,
settings.continueThreadsAfterServerUpdate,
settings.sidebarAutoSettleAfterDays,
settings.sidebarAutoSettleOnMerge,
@@ -819,6 +823,7 @@ export function useSettingsRestore(onRestored?: () => void) {
snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads,
responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
+ automaticUpdates: DEFAULT_UNIFIED_SETTINGS.automaticUpdates,
continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
@@ -2201,6 +2206,11 @@ export function GeneralSettingsPanel() {
connectedEnvironments.every(
(target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,
);
+ const supportsAutomaticUpdates =
+ connectedEnvironments.length > 0 &&
+ connectedEnvironments.every(
+ (target) => target.serverConfig?.environment.capabilities.automaticUpdates === true,
+ );
const supportsRestartContinuation =
connectedEnvironments.length > 0 &&
connectedEnvironments.every(
@@ -2910,6 +2920,38 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({ automaticUpdates: DEFAULT_UNIFIED_SETTINGS.automaticUpdates })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateSettings({ automaticUpdates: Boolean(checked) })}
+ aria-label="Update automatically"
+ />
+ }
+ />
+
Date: Fri, 9 Oct 2026 18:10:07 +0000
Subject: [PATCH 003/149] feat(server): environments update themselves when
nothing is running
Background service installs stage the newest release on their channel, then
hand off to the launcher once the update window opens. Desktop-hosted servers
relaunch the app into an update it already downloaded once no window is in
use. Both follow the Update automatically setting.
A rolled-back target is remembered by the launcher and not retried, and a
release that changes the orchestration protocol is left for a manual update
so connected clients are not locked out.
---
.../SettingsServerControlsRouteScreen.tsx | 2 +-
apps/server/src/cli/update.ts | 2 +-
apps/server/src/cloud/selfUpdate.test.ts | 9 +
apps/server/src/cloud/selfUpdate.ts | 210 +++++++++++-------
.../server/src/cloud/serviceLauncherClient.ts | 4 +
.../server/src/cloud/servicePreflight.test.ts | 2 +
apps/server/src/cloud/servicePreflight.ts | 15 +-
apps/server/src/server.ts | 6 +
.../src/serverRuntimeStartup.autoPull.test.ts | 1 +
apps/server/src/updates/ServerAutoUpdater.ts | 158 +++++++++++++
apps/server/src/updates/UpdateWindow.ts | 2 +
.../components/settings/SettingsPanels.tsx | 2 +-
docs/user/updating.md | 22 +-
13 files changed, 340 insertions(+), 95 deletions(-)
create mode 100644 apps/server/src/updates/ServerAutoUpdater.ts
diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
index 964f2a3f8c888..df70d72ad7be7 100644
--- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
@@ -473,7 +473,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
projectSelected
? "Environment-wide setting. Select All projects to change it."
: supportsAutomaticUpdates
- ? "Install provider CLI updates while nothing is running."
+ ? "Install T3 Code and provider updates while nothing is running."
: "Update older servers to control automatic updates."
}
value={uniform("automaticUpdates")}
diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts
index fa167e3bdeeb7..f2b8f28632082 100644
--- a/apps/server/src/cli/update.ts
+++ b/apps/server/src/cli/update.ts
@@ -62,7 +62,7 @@ const RELEASE_INDEX_TIMEOUT = Duration.seconds(30);
const RELEASE_INDEX_MAX_PAGES = 10;
/** Asks GitHub for the newest published version on a channel, page by page. */
-const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
+export const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
channel: CliReleaseChannel,
releaseBaseUrl: string | undefined,
) {
diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts
index 111b62c8a481e..577ad660c2a93 100644
--- a/apps/server/src/cloud/selfUpdate.test.ts
+++ b/apps/server/src/cloud/selfUpdate.test.ts
@@ -105,6 +105,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
return "launcher-id";
})),
prepareTrial: Effect.undefined,
+ lastOutcome: undefined,
});
const config = yield* ServerConfig.ServerConfig.pipe(
Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
@@ -127,6 +128,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
return { selfUpdate, order };
});
+const withoutStaging = { capability: null, stage: () => Effect.die("unused") } as const;
+
it.layer(NodeServices.layer)("server self update", (it) => {
it.effect("marks running threads at the boot-service handoff", () =>
Effect.gen(function* () {
@@ -134,6 +137,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "web",
selfUpdate: {
+ ...withoutStaging,
update: (_input, reportProgress = () => Effect.void) =>
reportProgress("downloading").pipe(
Effect.andThen(reportProgress("installing")),
@@ -168,6 +172,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "desktop",
selfUpdate: {
+ ...withoutStaging,
update: (_input, reportProgress = () => Effect.void) =>
reportProgress("installing").pipe(
Effect.as({
@@ -216,6 +221,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "web",
selfUpdate: {
+ ...withoutStaging,
update: (_input, reportProgress = () => Effect.void) =>
reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))),
commitDesktopUpdate: () => Effect.never,
@@ -238,6 +244,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "web",
selfUpdate: {
+ ...withoutStaging,
update: (
_input,
reportProgress = () => Effect.void,
@@ -271,6 +278,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "desktop",
selfUpdate: {
+ ...withoutStaging,
update: () =>
Effect.succeed({
targetVersion: "1.2.0",
@@ -307,6 +315,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "desktop",
selfUpdate: {
+ ...withoutStaging,
update: () =>
Effect.succeed({
targetVersion: "1.2.0",
diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts
index 47c4949de7392..a6d8c70740cc2 100644
--- a/apps/server/src/cloud/selfUpdate.ts
+++ b/apps/server/src/cloud/selfUpdate.ts
@@ -15,6 +15,7 @@ import * as Effect from "effect/Effect";
import * as HashSet from "effect/HashSet";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
+import * as Semaphore from "effect/Semaphore";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
@@ -59,6 +60,15 @@ export class ServerSelfUpdate extends Context.Service<
requestId: string,
onHandoffAccepted?: () => Effect.Effect,
) => Effect.Effect;
+ /** How this server can update itself, or null when it cannot. */
+ readonly capability: ServerSelfUpdateCapability | null;
+ /** Downloads and preflights a boot-service runtime without restarting. */
+ readonly stage: (
+ targetVersion: string,
+ ) => Effect.Effect<
+ { readonly orchestrationProtocol: number | undefined },
+ ServerSelfUpdateError
+ >;
}
>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
@@ -137,6 +147,7 @@ export const withRunningThreadContinuation = Effect.fn(
};
return ServerSelfUpdate.of({
+ ...input.selfUpdate,
update,
commitDesktopUpdate: (requestId) =>
Effect.gen(function* () {
@@ -185,6 +196,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
);
const inFlight = yield* Ref.make(false);
+ // A background stage and a manual update may target the same version.
+ const stageLock = yield* Semaphore.make(1);
const capability: ServerSelfUpdateCapability | null =
serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;
@@ -193,6 +206,109 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
? new ServerSelfUpdateError({ reason })
: new ServerSelfUpdateError({ reason, cause });
+ /** Downloads and preflights a runtime without handing off, so a background
+ update can stage first and restart later. */
+ const stageRuntime = Effect.fn("cloud.server_self_update.stage")(function* (
+ targetVersion: string,
+ ) {
+ return yield* stageLock.withPermits(1)(stageRuntimeUnlocked(targetVersion));
+ });
+ const stageRuntimeUnlocked = Effect.fn("cloud.server_self_update.stageUnlocked")(function* (
+ targetVersion: string,
+ ) {
+ let orchestrationProtocol: number | undefined;
+ const paths = yield* ensurePinnedRuntimeInstalled({
+ baseDir: serverConfig.baseDir,
+ version: targetVersion,
+ fs,
+ path,
+ runner,
+ httpClient,
+ platform,
+ arch,
+ releaseBaseUrl,
+ validate: (runtime) =>
+ runner
+ .run({
+ command: pinnedRuntimeCommand(runtime).command,
+ args: [
+ ...pinnedRuntimeCommand(runtime).args,
+ "__service-preflight",
+ "--database-path",
+ serverConfig.dbPath,
+ "--launcher-protocol",
+ String(SERVICE_LAUNCHER_PROTOCOL),
+ ],
+ timeout: PREFLIGHT_TIMEOUT,
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new PinnedRuntimeInstallError({
+ step: "running the staged service preflight",
+ cause,
+ }),
+ ),
+ Effect.flatMap(
+ (
+ result,
+ ): Effect.Effect<
+ void,
+ PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError
+ > => {
+ if (result.code !== 0) {
+ return Effect.fail(
+ new PinnedRuntimeInstallError({
+ step: "running the staged service preflight",
+ exitCode: Number(result.code),
+ stdoutLength: result.stdout.length,
+ stderrLength: result.stderr.length,
+ }),
+ );
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(result.stdout.trim());
+ } catch (cause) {
+ return Effect.fail(
+ new PinnedRuntimeInstallError({
+ step: "decoding the staged service preflight",
+ cause,
+ }),
+ );
+ }
+ const preflight = decodeServicePreflightResult(parsed);
+ if (preflight === undefined || preflight.version !== targetVersion) {
+ return Effect.fail(
+ new PinnedRuntimeInstallError({
+ step: "verifying the staged service preflight",
+ }),
+ );
+ }
+ if (preflight.status === "ready") {
+ orchestrationProtocol = preflight.orchestrationProtocol;
+ }
+ return preflight.status === "ready"
+ ? Effect.void
+ : Effect.fail(
+ new PinnedRuntimePreflightBlockedError({
+ version: targetVersion,
+ reason: preflight.reason,
+ }),
+ );
+ },
+ ),
+ ),
+ }).pipe(
+ Effect.mapError((error) =>
+ error._tag === "PinnedRuntimePreflightBlockedError"
+ ? failWith(error.reason, error)
+ : failWith(`Could not prepare t3@${targetVersion}.`, error),
+ ),
+ );
+ return { paths, orchestrationProtocol };
+ });
+
const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
"cloud.server_self_update.update",
)(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {
@@ -223,92 +339,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
return yield* Effect.gen(function* () {
yield* reportProgress("downloading");
- const paths = yield* ensurePinnedRuntimeInstalled({
- baseDir: serverConfig.baseDir,
- version: targetVersion,
- fs,
- path,
- runner,
- httpClient,
- platform,
- arch,
- releaseBaseUrl,
- validate: (runtime) =>
- runner
- .run({
- command: pinnedRuntimeCommand(runtime).command,
- args: [
- ...pinnedRuntimeCommand(runtime).args,
- "__service-preflight",
- "--database-path",
- serverConfig.dbPath,
- "--launcher-protocol",
- String(SERVICE_LAUNCHER_PROTOCOL),
- ],
- timeout: PREFLIGHT_TIMEOUT,
- })
- .pipe(
- Effect.mapError(
- (cause) =>
- new PinnedRuntimeInstallError({
- step: "running the staged service preflight",
- cause,
- }),
- ),
- Effect.flatMap(
- (
- result,
- ): Effect.Effect<
- void,
- PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError
- > => {
- if (result.code !== 0) {
- return Effect.fail(
- new PinnedRuntimeInstallError({
- step: "running the staged service preflight",
- exitCode: Number(result.code),
- stdoutLength: result.stdout.length,
- stderrLength: result.stderr.length,
- }),
- );
- }
- let parsed: unknown;
- try {
- parsed = JSON.parse(result.stdout.trim());
- } catch (cause) {
- return Effect.fail(
- new PinnedRuntimeInstallError({
- step: "decoding the staged service preflight",
- cause,
- }),
- );
- }
- const preflight = decodeServicePreflightResult(parsed);
- if (preflight === undefined || preflight.version !== targetVersion) {
- return Effect.fail(
- new PinnedRuntimeInstallError({
- step: "verifying the staged service preflight",
- }),
- );
- }
- return preflight.status === "ready"
- ? Effect.void
- : Effect.fail(
- new PinnedRuntimePreflightBlockedError({
- version: targetVersion,
- reason: preflight.reason,
- }),
- );
- },
- ),
- ),
- }).pipe(
- Effect.mapError((error) =>
- error._tag === "PinnedRuntimePreflightBlockedError"
- ? failWith(error.reason, error)
- : failWith(`Could not prepare t3@${targetVersion}.`, error),
- ),
- );
+ const { paths } = yield* stageRuntime(targetVersion);
yield* reportProgress("installing");
const updateId = yield* Effect.uninterruptible(
@@ -338,6 +369,13 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
update,
commitDesktopUpdate: (requestId, onHandoffAccepted) =>
desktopAppUpdate.commit(requestId, onHandoffAccepted),
+ capability,
+ stage: (targetVersion) =>
+ capability === "boot-service"
+ ? stageRuntime(targetVersion).pipe(
+ Effect.map(({ orchestrationProtocol }) => ({ orchestrationProtocol })),
+ )
+ : Effect.fail(failWith("Only background service installs can stage an update.")),
});
});
diff --git a/apps/server/src/cloud/serviceLauncherClient.ts b/apps/server/src/cloud/serviceLauncherClient.ts
index 2d644157211fd..dad88d986beaa 100644
--- a/apps/server/src/cloud/serviceLauncherClient.ts
+++ b/apps/server/src/cloud/serviceLauncherClient.ts
@@ -108,6 +108,9 @@ export class ServiceLauncherClient extends Context.Service<
ServerSelfUpdateOutcome | undefined,
ServiceLauncherClientError
>;
+ /** The launcher's last finished update, kept until the next one, so a
+ rolled-back target is not retried on every boot. */
+ readonly lastOutcome: ServerSelfUpdateOutcome | undefined;
}
>()("t3/cloud/serviceLauncherClient") {}
@@ -247,6 +250,7 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o
managed,
requestUpdate,
prepareTrial,
+ lastOutcome: outcome,
});
});
diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts
index 1b9cf8e46c41e..f8b6055ce5c36 100644
--- a/apps/server/src/cloud/servicePreflight.test.ts
+++ b/apps/server/src/cloud/servicePreflight.test.ts
@@ -1,4 +1,5 @@
import { expect, it } from "@effect/vitest";
+import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
import { runServicePreflight } from "./servicePreflight.ts";
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
@@ -29,5 +30,6 @@ it("accepts the current launcher protocol", () => {
status: "ready",
version: "1.2.3",
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
+ orchestrationProtocol: ORCHESTRATION_PROTOCOL_VERSION,
});
});
diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts
index ee0f972baa3fc..000863be47d18 100644
--- a/apps/server/src/cloud/servicePreflight.ts
+++ b/apps/server/src/cloud/servicePreflight.ts
@@ -1,3 +1,5 @@
+import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
+
import packageJson from "../../package.json" with { type: "json" };
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
@@ -6,6 +8,9 @@ export type ServicePreflightResult =
readonly status: "ready";
readonly version: string;
readonly launcherProtocol: typeof SERVICE_LAUNCHER_PROTOCOL;
+ /** Absent from runtimes that predate it. A background update skips a
+ target whose clients would need a newer protocol. */
+ readonly orchestrationProtocol?: number;
}
| {
readonly status: "blocked";
@@ -29,7 +34,12 @@ export function runServicePreflight(input: {
};
}
- return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL };
+ return {
+ status: "ready",
+ version,
+ launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
+ orchestrationProtocol: ORCHESTRATION_PROTOCOL_VERSION,
+ };
}
export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined {
@@ -46,6 +56,9 @@ export function decodeServicePreflightResult(value: unknown): ServicePreflightRe
status: "ready",
version: record.version,
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
+ ...(typeof record.orchestrationProtocol === "number"
+ ? { orchestrationProtocol: record.orchestrationProtocol }
+ : {}),
};
}
if (
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index c4be403306d84..103ed184693c4 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -159,6 +159,7 @@ import * as UsageService from "./usage/UsageService.ts";
import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";
import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
import * as ProviderAutoUpdater from "./updates/ProviderAutoUpdater.ts";
+import * as ServerAutoUpdater from "./updates/ServerAutoUpdater.ts";
import * as UpdateWindow from "./updates/UpdateWindow.ts";
import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";
@@ -708,6 +709,11 @@ const layerMakeRoutes = Layer.mergeAll(
Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry),
Layer.provide(McpOAuth.layerMcpClientAuthenticator),
),
+ // Shares the routes' ServerSelfUpdate, so a manual and a background update never overlap.
+ ServerAutoUpdater.layer.pipe(
+ Layer.provide(UpdateWindow.layer.pipe(Layer.provide(ProjectionStoreV2.layer))),
+ Layer.provide(layerDesktopTelemetryReceiver),
+ ),
).pipe(
// Both transports consume the same service instance, so caches single-flight across clients
// and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts
index 8313d33dc220d..8b80251ceb95f 100644
--- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts
+++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts
@@ -150,6 +150,7 @@ it.effect("parks automatic pull until activation without delaying command readin
Layer.mock(ThreadLaunch.ThreadLaunchService)({}),
Layer.mock(ServiceLauncherClient.ServiceLauncherClient)({
managed: true,
+ lastOutcome: undefined,
prepareTrial: Deferred.succeed(prepared, undefined).pipe(
Effect.andThen(Deferred.await(commitTrial)),
Effect.as(undefined),
diff --git a/apps/server/src/updates/ServerAutoUpdater.ts b/apps/server/src/updates/ServerAutoUpdater.ts
new file mode 100644
index 0000000000000..e5945feb463b6
--- /dev/null
+++ b/apps/server/src/updates/ServerAutoUpdater.ts
@@ -0,0 +1,158 @@
+import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
+import { CLI_RELEASE_BASE_URL_ENV, cliReleaseChannelOf } from "@t3tools/shared/cliRelease";
+import * as Config from "effect/Config";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schedule from "effect/Schedule";
+import * as Stream from "effect/Stream";
+import { HttpClient } from "effect/http";
+
+import packageJson from "../../package.json" with { type: "json" };
+import { resolveNewestVersion } from "../cli/update.ts";
+import * as ServerSelfUpdate from "../cloud/selfUpdate.ts";
+import * as ServerConfig from "../config.ts";
+import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts";
+import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
+import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
+import { forkParked } from "../serverActivation.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import * as UpdateWindow from "./UpdateWindow.ts";
+
+/**
+ * Updates this environment in the background when `automaticUpdates` is on.
+ *
+ * - Background service installs stage the newest release on their channel,
+ * then hand off to the launcher once the update window opens.
+ * - Desktop-hosted servers wait for the app to finish its background
+ * download, then relaunch it through the existing prepare/commit handoff
+ * once no window is in use.
+ * - Foreground and `npx` servers cannot replace themselves and are skipped.
+ */
+export const layer = Layer.effectDiscard(
+ Effect.gen(function* () {
+ // A dev server must not relaunch the desktop app or service it runs beside.
+ if ((yield* ServerConfig.ServerConfig).devUrl !== undefined) return;
+ const selfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
+ const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
+ const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
+ const updateWindow = yield* UpdateWindow.UpdateWindow;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const timings = yield* UpdateWindow.updateTimings;
+ const httpClient = yield* HttpClient.HttpClient;
+ // Same mirror the staged download uses, so a mirror lists what it serves.
+ const releaseBaseUrl = Option.getOrUndefined(
+ yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
+ );
+ const currentVersion = packageJson.version;
+ // Targets this process already gave up on; the launcher remembers rollbacks across restarts.
+ const skippedTargets = new Set();
+ if (launcher.lastOutcome !== undefined && launcher.lastOutcome.status !== "committed") {
+ skippedTargets.add(launcher.lastOutcome.targetVersion);
+ }
+
+ const automaticUpdatesEnabled = serverSettings.getSettings.pipe(
+ Effect.map((settings) => settings.automaticUpdates),
+ Effect.orElseSucceed(() => false),
+ );
+
+ const updateBootService = Effect.gen(function* () {
+ if (!(yield* automaticUpdatesEnabled)) return;
+ const targetVersion = yield* resolveNewestVersion(
+ cliReleaseChannelOf(currentVersion),
+ releaseBaseUrl,
+ ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
+ if (
+ compareExactServiceVersions(targetVersion, currentVersion) <= 0 ||
+ skippedTargets.has(targetVersion)
+ ) {
+ return;
+ }
+ yield* Effect.logInfo("Staging a background server update", { targetVersion });
+ const staged = yield* selfUpdate.stage(targetVersion);
+ if (
+ staged.orchestrationProtocol !== undefined &&
+ staged.orchestrationProtocol !== ORCHESTRATION_PROTOCOL_VERSION
+ ) {
+ // Connected clients would be refused until they update; leave this one to a person.
+ skippedTargets.add(targetVersion);
+ yield* Effect.logInfo("Skipping a background update that changes the client protocol", {
+ targetVersion,
+ });
+ return;
+ }
+ yield* updateWindow.runWhenOpen(
+ { closesWindows: false },
+ Effect.gen(function* () {
+ // The setting may have been turned off while waiting.
+ if (!(yield* automaticUpdatesEnabled)) return;
+ skippedTargets.add(targetVersion);
+ yield* Effect.logInfo("Installing a background server update", { targetVersion });
+ yield* selfUpdate.update({ targetVersion });
+ }),
+ );
+ }).pipe(
+ Effect.catchCause((cause) => Effect.logWarning("Background server update failed", { cause })),
+ Effect.withSpan("updates.ServerAutoUpdater.bootService"),
+ );
+
+ const updateDesktopApp = (downloadedVersion: string) =>
+ Effect.gen(function* () {
+ if (!(yield* automaticUpdatesEnabled) || skippedTargets.has(downloadedVersion)) return;
+ yield* updateWindow.runWhenOpen(
+ { closesWindows: true },
+ Effect.gen(function* () {
+ if (!(yield* automaticUpdatesEnabled)) return;
+ skippedTargets.add(downloadedVersion);
+ yield* Effect.logInfo("Relaunching the desktop app into a downloaded update", {
+ downloadedVersion,
+ });
+ // The download is already on disk, so preparing it is quick.
+ const prepared = yield* selfUpdate.update({ targetVersion: downloadedVersion });
+ if (prepared.desktopUpdateToken === undefined) return;
+ return yield* selfUpdate.commitDesktopUpdate(prepared.desktopUpdateToken);
+ }),
+ );
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Background desktop update failed", { cause }),
+ ),
+ Effect.withSpan("updates.ServerAutoUpdater.desktopApp"),
+ );
+
+ if (selfUpdate.capability === "boot-service") {
+ yield* forkParked(
+ Effect.sleep(timings.firstPassDelay).pipe(
+ Effect.andThen(
+ updateBootService.pipe(Effect.repeat(Schedule.spaced(timings.releaseCheckInterval))),
+ ),
+ ),
+ );
+ }
+
+ if (selfUpdate.capability === "desktop-managed") {
+ // The desktop app reports its updater state on attach and on every change.
+ yield* forkParked(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const { latest, changes } = yield* receiver.desktopUpdates;
+ const reports = Option.match(latest, {
+ onNone: () => changes,
+ onSome: (report) => Stream.concat(Stream.make(report), changes),
+ });
+ yield* reports.pipe(
+ Stream.map((report) =>
+ report.state.status === "downloaded" ? report.state.downloadedVersion : null,
+ ),
+ Stream.filter((version): version is string => version !== null),
+ Stream.changes,
+ // A newer download replaces a wait for an older one.
+ Stream.switchMap((version) => Stream.fromEffect(updateDesktopApp(version))),
+ Stream.runDrain,
+ );
+ }),
+ ),
+ );
+ }
+ }),
+);
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index be9e91055e840..a4ea69fcdff98 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -38,6 +38,8 @@ export const updateTimings = Config.Boolean("T3CODE_UPDATE_LAB").pipe(
recheck: lab ? Duration.seconds(5) : Duration.seconds(30),
firstPassDelay: lab ? Duration.seconds(10) : Duration.minutes(2),
passInterval: lab ? Duration.seconds(20) : Duration.minutes(10),
+ /** T3 releases are checked this often; the desktop app polls its own feed. */
+ releaseCheckInterval: lab ? Duration.seconds(20) : Duration.hours(1),
})),
);
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index f5b07cfc8beee..bd955729620ae 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -2924,7 +2924,7 @@ export function GeneralSettingsPanel() {
serverScoped
settingKeys={["automaticUpdates"]}
{...searchableSetting("automatic-updates")}
- description="Install provider CLI updates in the background on the selected environments while nothing is running."
+ description="Install T3 Code and provider CLI updates in the background on the selected environments while nothing is running."
status={
!supportsAutomaticUpdates
? "All selected connected environments must support automatic updates."
diff --git a/docs/user/updating.md b/docs/user/updating.md
index 8c2bf919fa350..2d24d3a45e0b3 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -72,13 +72,25 @@ update can roll back to the previous version. If the update still fails:
2. Check that you updated the server's machine, not only the device you are using.
3. For a command-line server, stop it and relaunch the exact version shown in the notice.
-## Update providers
+## Automatic updates
**Settings → General → Update automatically** is on by default. Each environment
-installs provider CLI updates in the background once no thread is running or
-waiting for you, no client has been used for 15 minutes, and no scheduled task
-or usage-limit resume is due within 5 minutes. A failed update is retried
-after 6 hours or when a newer version is published.
+waits until no thread is running or waiting for you, no client has been used for
+15 minutes, and no scheduled task or usage-limit resume is due within 5 minutes.
+Then it installs:
+
+- provider CLI updates that have a one-click update;
+- the newest T3 Code release on its channel, when it runs as a background service
+ (`t3 service install`). It downloads first and restarts only inside that window;
+- a desktop app update that has finished downloading, when no desktop window is
+ focused or the machine has been idle for 15 minutes.
+
+Servers started from a terminal or with `npx` are never replaced automatically.
+A failed provider update is retried after 6 hours or when a newer version is
+published. A failed or rolled-back T3 Code update is not retried until a newer
+version is published. An update that needs newer clients waits for you to install it.
+
+## Update providers
**Settings → Providers** shows provider updates for the selected environment.
**Update all** updates every outdated provider on every connected environment
From 08b23cc7072ef6f336cb91854719868c4415af65 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:10:47 +0000
Subject: [PATCH 004/149] feat(desktop): download updates in the background and
install on quit
The desktop app downloads an update as soon as a check finds one, instead of
waiting for a click. Its server relaunches the app into it once nothing is
running and no window is in use; quitting the app installs it too.
---
.../src/updates/DesktopUpdates.test.ts | 5 +--
apps/desktop/src/updates/DesktopUpdates.ts | 34 ++++++++++++++++++-
docs/user/updating.md | 5 +--
3 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts
index 9745662c1209a..c97c22e622870 100644
--- a/apps/desktop/src/updates/DesktopUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.test.ts
@@ -205,7 +205,7 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
- it.effect("checks for newer releases after an update has been downloaded", () => {
+ it.effect("downloads newer releases found after an update has been downloaded", () => {
const harness = makeHarness();
return Effect.scoped(
@@ -241,8 +241,9 @@ describe("DesktopUpdates", () => {
harness.emit("update-available", { version: "1.2.5" });
yield* flushCallbacks;
+ // A release the background poller finds starts downloading on its own.
const state = yield* updates.getState;
- assert.equal(state.status, "available");
+ assert.equal(state.status, "downloading");
assert.equal(state.availableVersion, "1.2.5");
assert.isNull(state.downloadedVersion);
}),
diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts
index 245925a6e7319..4c49f3c39d8fc 100644
--- a/apps/desktop/src/updates/DesktopUpdates.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.ts
@@ -50,6 +50,7 @@ import {
const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90);
+const BACKGROUND_DOWNLOAD_START_WAIT = Duration.minutes(2);
type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel";
@@ -289,6 +290,7 @@ export const make = Effect.gen(function* () {
const finishedUpdateActions = yield* PubSub.unbounded();
const updaterConfiguredRef = yield* Ref.make(false);
const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
+ const lastCheckReasonRef = yield* Ref.make(null);
const updateStateRef = yield* Ref.make(
createInitialDesktopUpdateState(
environment.appVersion,
@@ -425,6 +427,7 @@ export const make = Effect.gen(function* () {
if (actionReservation === "acquire" && !(yield* tryStartUpdateAction("check"))) return false;
+ yield* Ref.set(lastCheckReasonRef, reason);
const check = Effect.gen(function* () {
const checkedAt = yield* currentIsoTimestamp;
yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
@@ -516,6 +519,28 @@ export const make = Effect.gen(function* () {
);
}).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
+ // update-available fires inside the check that found it, so the download
+ // waits for that check to release the updater. Installing stays gated: the
+ // app's server relaunches into it when idle, and quitting installs it.
+ const downloadInBackground = Effect.scoped(
+ Effect.gen(function* () {
+ const completions = yield* PubSub.subscribe(finishedUpdateActions);
+ while (Option.isSome(yield* activeUpdateAction)) {
+ yield* PubSub.take(completions);
+ }
+ if (yield* Ref.get(desktopState.quitting)) return;
+ yield* downloadAvailableUpdate;
+ }),
+ ).pipe(
+ Effect.timeout(BACKGROUND_DOWNLOAD_START_WAIT),
+ Effect.catchCause((cause) =>
+ Cause.hasInterruptsOnly(cause)
+ ? Effect.void
+ : logUpdaterWarning("background update download did not start"),
+ ),
+ Effect.withSpan("desktop.updates.downloadInBackground"),
+ );
+
// Tells the primary backend that the coming stop is an update restart, so it
// keeps its managed tunnel for the backend the updated app starts. Best
// effort: without the marker the backend only re-provisions its tunnel.
@@ -773,6 +798,12 @@ export const make = Effect.gen(function* () {
releaseNoteGroups: releaseNotes.length,
omittedReleaseCount,
});
+ // Updates found by the background pollers download on their own; a
+ // check someone started keeps its Download button.
+ const reason = yield* Ref.get(lastCheckReasonRef);
+ if (reason === "startup" || reason === "poll") {
+ yield* Effect.forkDetach(downloadInBackground);
+ }
}),
),
Effect.catchCause((cause) => {
@@ -931,7 +962,8 @@ export const make = Effect.gen(function* () {
yield* Ref.set(updaterConfiguredRef, true);
yield* electronUpdater.setAutoDownload(false);
- yield* electronUpdater.setAutoInstallOnAppQuit(false);
+ // A download that is never relaunched into installs when the user quits.
+ yield* electronUpdater.setAutoInstallOnAppQuit(true);
yield* applyAutoUpdaterChannel(settings.updateChannel);
yield* electronUpdater.setDisableDifferentialDownload(
isArm64HostRunningIntelBuild(environment.runtimeInfo),
diff --git a/docs/user/updating.md b/docs/user/updating.md
index 2d24d3a45e0b3..a7b4dda394aae 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -82,8 +82,9 @@ Then it installs:
- provider CLI updates that have a one-click update;
- the newest T3 Code release on its channel, when it runs as a background service
(`t3 service install`). It downloads first and restarts only inside that window;
-- a desktop app update that has finished downloading, when no desktop window is
- focused or the machine has been idle for 15 minutes.
+- a desktop app update, which downloads in the background as soon as it is
+ published and installs when no desktop window is focused or the machine has
+ been idle for 15 minutes. Quitting the app also installs a downloaded update.
Servers started from a terminal or with `npx` are never replaced automatically.
A failed provider update is retried after 6 hours or when a newer version is
From 51fc7141533589a3ac7811fb4a4699c027f438ef Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:11:20 +0000
Subject: [PATCH 005/149] feat(settings): continue threads after restarts by
default
Background updates restart environments without asking, so threads they
interrupt now resume unless the user turns this off.
---
docs/internals/server-updates.md | 2 +-
docs/user/updating.md | 6 ++----
packages/contracts/src/settings.ts | 5 +++--
3 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md
index 34fa5aa47b094..0c8d084656092 100644
--- a/docs/internals/server-updates.md
+++ b/docs/internals/server-updates.md
@@ -58,7 +58,7 @@ stopped backends and replays the failure for the same token.
## Recovering interrupted threads
-Restart continuation is an environment-owned preference, off by default. The
+Restart continuation is an environment-owned preference, on by default. The
[v2 recovery service](../../apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts)
requires matching durable run, provider thread, session, and native resume identity.
Queued runs never started, so recovery holds them and continues the run they wait
diff --git a/docs/user/updating.md b/docs/user/updating.md
index a7b4dda394aae..6b9c5094bcbcb 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -10,16 +10,14 @@ notice.
Server updates restart the connection and can interrupt active agents and
terminal commands. Saved threads, settings, and project files remain.
-**Settings → General → Continue threads after restarts** is off by default.
-Enable it to resume supported active threads after an update, crash, or machine
+**Settings → General → Continue threads after restarts** is on by default.
+It resumes supported active threads after an update, crash, or machine
restart. Changes are saved to connected environments that support this setting;
update older servers first. If a supported environment was offline or has a
different value, use **Apply to all** in Settings after it connects.
T3 Code must start again on that machine;
the setting does not enable automatic startup. Terminal commands may still be
interrupted, and threads without saved provider resume state need a new message.
-If you previously enabled continuation for updates, enable this setting once
-to allow recovery without a connected client.
Updates from the previous orchestration system preserve conversation transcripts but cannot carry
every kind of runtime history forward. Read [Threads from older T3 Code versions](./thread-migration.md)
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 1ad8bf48a9957..71bd431b9d134 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1069,9 +1069,10 @@ export const ServerSettings = Schema.Struct({
enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
/** Install updates in the background once nothing is running and nobody is using the app. */
automaticUpdates: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
- // Retain the update-era key; recovery now needs an environment-owned opt-in.
+ // Retain the update-era key. On by default so background updates and
+ // crashes resume the work they interrupted.
continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
- Schema.withDecodingDefault(Effect.succeed(false)),
+ Schema.withDecodingDefault(Effect.succeed(true)),
),
/**
* Whether agents may drive the in-app preview browser. Turning this off
From 26af088ce1f376657bbe4c9416f6a196372f68ec Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:45:28 +0000
Subject: [PATCH 006/149] build: register the update lab as a package script
Knip treats package scripts as entry points.
---
package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/package.json b/package.json
index 55869fc4b3911..2b839155031ab 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"start:desktop": "vp run --filter @t3tools/desktop start",
"start:marketing": "vp run --filter @t3tools/marketing preview",
"start:mock-update-server": "node scripts/mock-update-server.ts",
+ "update-lab": "node scripts/update-lab.ts",
"screenshots:mobile": "node scripts/mobile-showcase.ts",
"icons:export": "node scripts/export-brand-icons.ts",
"icons:check": "node scripts/export-brand-icons.ts --check",
From 08d378e1c1060c6d6495e473d89369bfbe7db07d Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:11:18 +0000
Subject: [PATCH 007/149] fix(server): keep background updates behind pending
work
---
.../Adapters/ClaudeAdapterV2.test.ts | 8 +++++
.../Adapters/ClaudeAdapterV2.ts | 2 +-
.../orchestration-v2/ProjectionStore.test.ts | 7 ++++
.../src/orchestration-v2/ProjectionStore.ts | 14 ++++++++
apps/server/src/updates/UpdateWindow.ts | 35 ++++++++++---------
5 files changed, 49 insertions(+), 17 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index bfef7f9e9a104..5e31ce93bac4b 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -3132,6 +3132,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
cacheCreationTokens: 0,
});
}
+ const settledThread = harness.events.findLast(
+ (event) => event.type === "provider_thread.updated",
+ );
+ assert.equal(settledThread?.type, "provider_thread.updated");
+ if (settledThread?.type === "provider_thread.updated") {
+ assert.equal(settledThread.providerThread.status, "idle");
+ assert.isEmpty(settledThread.providerThread.pendingBackgroundTasks ?? []);
+ }
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),
);
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index de747802d3490..848897d78cffe 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -5217,7 +5217,7 @@ export function makeClaudeAdapterV2(
input.context.input.runOrdinal,
lastRunOrdinal: input.context.input.runOrdinal,
pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster),
- status: input.status === "completed" ? "active" : "idle",
+ status: input.status === "completed" && roster.size > 0 ? "active" : "idle",
updatedAt: input.completedAt,
};
yield* rememberProviderThread(providerThread);
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index e8a3c1bcfc08e..f51e2f3ecde06 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
@@ -337,6 +337,7 @@ it.effect("memory recovery selection ignores unfinished items from rolled-back r
const threadId = yield* addRolledBackRecoveryCandidate("memory-recovery-candidates");
assert.notInclude(yield* projectionStore.getRecoveryThreadIds("runtime"), threadId);
+ assert.notInclude(yield* projectionStore.getRecoveryThreadIds("active-work"), threadId);
}).pipe(Effect.provide(ProjectionStore.layerMemory)),
);
@@ -346,6 +347,7 @@ it.effect("memory recovery selection includes unfinished items from missing runs
const threadId = yield* addOrphanedRecoveryCandidate("memory-recovery-candidates");
assert.include(yield* projectionStore.getRecoveryThreadIds("runtime"), threadId);
+ assert.include(yield* projectionStore.getRecoveryThreadIds("active-work"), threadId);
}).pipe(Effect.provide(ProjectionStore.layerMemory)),
);
@@ -2416,6 +2418,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
assert.include(recoveryThreadIds, orphanedThreadId);
assert.notInclude(recoveryThreadIds, settledThreadId);
assert.notInclude(recoveryThreadIds, rolledBackThreadId);
+ const activeThreadIds = yield* projectionStore.getRecoveryThreadIds("active-work");
+ assert.include(activeThreadIds, runningThreadId);
+ assert.include(activeThreadIds, orphanedThreadId);
+ assert.notInclude(activeThreadIds, settledThreadId);
+ assert.notInclude(activeThreadIds, rolledBackThreadId);
}),
);
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index 8119dba91fc95..abc736ec8e94d 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -578,6 +578,12 @@ function needsRecovery(
projection.providerThreads.some(
(thread) =>
thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0,
+ ) ||
+ projection.turnItems.some(
+ (item) =>
+ ["command_execution", "dynamic_tool", "subagent"].includes(item.type) &&
+ ["pending", "running", "waiting"].includes(item.status) &&
+ !projection.runs.some((run) => run.id === item.runId && run.status === "rolled_back"),
)
);
case "runtime":
@@ -3627,6 +3633,14 @@ export const layer: Layer.Layer =
OR CASE WHEN json_valid(payload_json)
THEN json_array_length(payload_json, '$.pendingBackgroundTasks') > 0
ELSE 0 END
+ UNION
+ SELECT item.thread_id FROM orchestration_v2_projection_turn_items AS item
+ WHERE NOT EXISTS (
+ SELECT 1 FROM orchestration_v2_projection_runs AS run
+ WHERE run.run_id = item.run_id AND run.status = 'rolled_back'
+ )
+ AND type IN ('command_execution', 'dynamic_tool', 'subagent')
+ AND status IN ('pending', 'running', 'waiting')
`;
case "runtime":
return sql`
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index be9e91055e840..023794a69e900 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -16,11 +16,6 @@ import * as ServerSettings from "../serverSettings.ts";
/** Work due this soon keeps the window closed, so it does not start late. */
const UPDATE_LOOKAHEAD = Duration.minutes(5);
-/**
- * Tasks repeating at least this often always have a run due soon. Missed runs
- * catch up after a restart, so they only block while running.
- */
-const FREQUENT_TASK_INTERVAL = Duration.minutes(15);
/**
* Background update pacing. `T3CODE_UPDATE_LAB=1` (scripts/update-lab.ts)
@@ -93,7 +88,7 @@ export class UpdateWindow extends Context.Service<
}
>()("t3/updates/UpdateWindow") {}
-export const make = Effect.fn("updates.UpdateWindow.make")(function* () {
+const make = Effect.fn("updates.UpdateWindow.make")(function* () {
const projections = yield* ProjectionStore.ProjectionStoreV2;
const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
@@ -171,12 +166,6 @@ export const make = Effect.fn("updates.UpdateWindow.make")(function* () {
continue;
}
if (!task.enabled || task.nextRunAt === null) continue;
- if (
- task.schedule.type === "interval" &&
- task.schedule.everyMs < Duration.toMillis(FREQUENT_TASK_INTERVAL)
- ) {
- continue;
- }
const runsAt = DateTime.make(task.nextRunAt);
if (Option.isSome(runsAt) && !DateTime.isGreaterThan(runsAt.value, horizon)) {
blockers.push({
@@ -188,12 +177,26 @@ export const make = Effect.fn("updates.UpdateWindow.make")(function* () {
}
}
- const settings = yield* serverSettings.getSettings.pipe(Effect.option);
- if (Option.isSome(settings) && settings.value.autoResumeLimitedThreads) {
+ const settings = yield* serverSettings.getSettings.pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read settings", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(settings) || !settings.value.automaticUpdates) {
+ return { open: false, blockers };
+ }
+ if (settings.value.autoResumeLimitedThreads) {
const limited = yield* projections
.getLimitRecoveryCandidates({ now: horizon, autoResume: true, snooze: false })
- .pipe(Effect.orElseSucceed(() => []));
- for (const thread of limited) {
+ .pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read usage-limit resumes", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(limited)) return { open: false, blockers };
+ for (const thread of limited.value) {
if (thread.limitRecovery?.autoResume !== true) continue;
const resumesAt = DateTime.make(thread.limitRecovery.resetAt);
if (Option.isSome(resumesAt)) {
From 47f38cacf164751c978009a95ef2d2570abaa8bf Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:12:20 +0000
Subject: [PATCH 008/149] fix(server): refresh staged updates without
relaunching active apps
---
apps/server/src/updates/ServerAutoUpdater.ts | 134 +++++++------------
docs/user/updating.md | 7 +-
2 files changed, 51 insertions(+), 90 deletions(-)
diff --git a/apps/server/src/updates/ServerAutoUpdater.ts b/apps/server/src/updates/ServerAutoUpdater.ts
index e5945feb463b6..6b23b5555348c 100644
--- a/apps/server/src/updates/ServerAutoUpdater.ts
+++ b/apps/server/src/updates/ServerAutoUpdater.ts
@@ -1,11 +1,12 @@
import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
import { CLI_RELEASE_BASE_URL_ENV, cliReleaseChannelOf } from "@t3tools/shared/cliRelease";
+import * as Clock from "effect/Clock";
import * as Config from "effect/Config";
+import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schedule from "effect/Schedule";
-import * as Stream from "effect/Stream";
import { HttpClient } from "effect/http";
import packageJson from "../../package.json" with { type: "json" };
@@ -14,7 +15,6 @@ import * as ServerSelfUpdate from "../cloud/selfUpdate.ts";
import * as ServerConfig from "../config.ts";
import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts";
import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
-import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
import { forkParked } from "../serverActivation.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as UpdateWindow from "./UpdateWindow.ts";
@@ -24,9 +24,8 @@ import * as UpdateWindow from "./UpdateWindow.ts";
*
* - Background service installs stage the newest release on their channel,
* then hand off to the launcher once the update window opens.
- * - Desktop-hosted servers wait for the app to finish its background
- * download, then relaunch it through the existing prepare/commit handoff
- * once no window is in use.
+ * - Desktop-hosted servers update when the user quits the app. One backend
+ * cannot establish that every other backend the app hosts is idle.
* - Foreground and `npx` servers cannot replace themselves and are skipped.
*/
export const layer = Layer.effectDiscard(
@@ -34,8 +33,8 @@ export const layer = Layer.effectDiscard(
// A dev server must not relaunch the desktop app or service it runs beside.
if ((yield* ServerConfig.ServerConfig).devUrl !== undefined) return;
const selfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
+ if (selfUpdate.capability !== "boot-service") return;
const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
- const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
const updateWindow = yield* UpdateWindow.UpdateWindow;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const timings = yield* UpdateWindow.updateTimings;
@@ -45,6 +44,8 @@ export const layer = Layer.effectDiscard(
yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
);
const currentVersion = packageJson.version;
+ let pendingTarget: string | undefined;
+ let nextReleaseCheckAt = 0;
// Targets this process already gave up on; the launcher remembers rollbacks across restarts.
const skippedTargets = new Set();
if (launcher.lastOutcome !== undefined && launcher.lastOutcome.status !== "committed") {
@@ -58,34 +59,46 @@ export const layer = Layer.effectDiscard(
const updateBootService = Effect.gen(function* () {
if (!(yield* automaticUpdatesEnabled)) return;
- const targetVersion = yield* resolveNewestVersion(
- cliReleaseChannelOf(currentVersion),
- releaseBaseUrl,
- ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
- if (
- compareExactServiceVersions(targetVersion, currentVersion) <= 0 ||
- skippedTargets.has(targetVersion)
- ) {
- return;
- }
- yield* Effect.logInfo("Staging a background server update", { targetVersion });
- const staged = yield* selfUpdate.stage(targetVersion);
- if (
- staged.orchestrationProtocol !== undefined &&
- staged.orchestrationProtocol !== ORCHESTRATION_PROTOCOL_VERSION
- ) {
- // Connected clients would be refused until they update; leave this one to a person.
- skippedTargets.add(targetVersion);
- yield* Effect.logInfo("Skipping a background update that changes the client protocol", {
- targetVersion,
- });
- return;
+ const nowMs = yield* Clock.currentTimeMillis;
+ if (nowMs >= nextReleaseCheckAt) {
+ nextReleaseCheckAt = nowMs + Duration.toMillis(timings.releaseCheckInterval);
+ const targetVersion = yield* resolveNewestVersion(
+ cliReleaseChannelOf(currentVersion),
+ releaseBaseUrl,
+ ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
+ if (
+ compareExactServiceVersions(targetVersion, currentVersion) > 0 &&
+ !skippedTargets.has(targetVersion) &&
+ targetVersion !== pendingTarget
+ ) {
+ // A newer release replaces the pending target even while the window stays closed.
+ pendingTarget = undefined;
+ yield* Effect.logInfo("Staging a background server update", { targetVersion });
+ const staged = yield* selfUpdate
+ .stage(targetVersion)
+ .pipe(Effect.tapError(() => Effect.sync(() => skippedTargets.add(targetVersion))));
+ if (staged.orchestrationProtocol !== ORCHESTRATION_PROTOCOL_VERSION) {
+ // Without a matching protocol, connected clients could be refused after restart.
+ skippedTargets.add(targetVersion);
+ yield* Effect.logInfo(
+ "Skipping a background update without a matching client protocol",
+ {
+ targetVersion,
+ },
+ );
+ } else {
+ pendingTarget = targetVersion;
+ }
+ }
}
- yield* updateWindow.runWhenOpen(
+ const targetVersion = pendingTarget;
+ if (targetVersion === undefined) return;
+ yield* updateWindow.runIfOpen(
{ closesWindows: false },
Effect.gen(function* () {
// The setting may have been turned off while waiting.
if (!(yield* automaticUpdatesEnabled)) return;
+ pendingTarget = undefined;
skippedTargets.add(targetVersion);
yield* Effect.logInfo("Installing a background server update", { targetVersion });
yield* selfUpdate.update({ targetVersion });
@@ -96,63 +109,10 @@ export const layer = Layer.effectDiscard(
Effect.withSpan("updates.ServerAutoUpdater.bootService"),
);
- const updateDesktopApp = (downloadedVersion: string) =>
- Effect.gen(function* () {
- if (!(yield* automaticUpdatesEnabled) || skippedTargets.has(downloadedVersion)) return;
- yield* updateWindow.runWhenOpen(
- { closesWindows: true },
- Effect.gen(function* () {
- if (!(yield* automaticUpdatesEnabled)) return;
- skippedTargets.add(downloadedVersion);
- yield* Effect.logInfo("Relaunching the desktop app into a downloaded update", {
- downloadedVersion,
- });
- // The download is already on disk, so preparing it is quick.
- const prepared = yield* selfUpdate.update({ targetVersion: downloadedVersion });
- if (prepared.desktopUpdateToken === undefined) return;
- return yield* selfUpdate.commitDesktopUpdate(prepared.desktopUpdateToken);
- }),
- );
- }).pipe(
- Effect.catchCause((cause) =>
- Effect.logWarning("Background desktop update failed", { cause }),
- ),
- Effect.withSpan("updates.ServerAutoUpdater.desktopApp"),
- );
-
- if (selfUpdate.capability === "boot-service") {
- yield* forkParked(
- Effect.sleep(timings.firstPassDelay).pipe(
- Effect.andThen(
- updateBootService.pipe(Effect.repeat(Schedule.spaced(timings.releaseCheckInterval))),
- ),
- ),
- );
- }
-
- if (selfUpdate.capability === "desktop-managed") {
- // The desktop app reports its updater state on attach and on every change.
- yield* forkParked(
- Effect.scoped(
- Effect.gen(function* () {
- const { latest, changes } = yield* receiver.desktopUpdates;
- const reports = Option.match(latest, {
- onNone: () => changes,
- onSome: (report) => Stream.concat(Stream.make(report), changes),
- });
- yield* reports.pipe(
- Stream.map((report) =>
- report.state.status === "downloaded" ? report.state.downloadedVersion : null,
- ),
- Stream.filter((version): version is string => version !== null),
- Stream.changes,
- // A newer download replaces a wait for an older one.
- Stream.switchMap((version) => Stream.fromEffect(updateDesktopApp(version))),
- Stream.runDrain,
- );
- }),
- ),
- );
- }
+ yield* forkParked(
+ Effect.sleep(timings.firstPassDelay).pipe(
+ Effect.andThen(updateBootService.pipe(Effect.repeat(Schedule.spaced(timings.recheck)))),
+ ),
+ );
}),
);
diff --git a/docs/user/updating.md b/docs/user/updating.md
index 2d24d3a45e0b3..c1c566d3b758f 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -81,9 +81,10 @@ Then it installs:
- provider CLI updates that have a one-click update;
- the newest T3 Code release on its channel, when it runs as a background service
- (`t3 service install`). It downloads first and restarts only inside that window;
-- a desktop app update that has finished downloading, when no desktop window is
- focused or the machine has been idle for 15 minutes.
+ (`t3 service install`). It downloads first and restarts only inside that window.
+
+Desktop updates download in the background and install when you quit the app.
+The app stays open while you work, including when it hosts multiple backends.
Servers started from a terminal or with `npx` are never replaced automatically.
A failed provider update is retried after 6 hours or when a newer version is
From bd677e13c028187bce41ceac55576bfc3ab95df6 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:13:08 +0000
Subject: [PATCH 009/149] fix(desktop): keep slow background downloads reserved
---
.../src/updates/DesktopUpdates.test.ts | 60 +++++++++++++++++++
apps/desktop/src/updates/DesktopUpdates.ts | 11 ++--
2 files changed, 67 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts
index c97c22e622870..ce5935fe2fb3e 100644
--- a/apps/desktop/src/updates/DesktopUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.test.ts
@@ -250,6 +250,66 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
+ it.effect("keeps slow background downloads active after waiting for a check", () =>
+ Effect.gen(function* () {
+ const checkStarted = yield* Deferred.make();
+ const releaseCheck = yield* Deferred.make();
+ const downloadStarted = yield* Deferred.make();
+ const releaseDownload = yield* Deferred.make();
+ const harness = makeHarness({
+ checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseCheck)),
+ ),
+ downloadUpdate: Deferred.succeed(downloadStarted, undefined).pipe(
+ Effect.andThen(Deferred.await(releaseDownload)),
+ ),
+ });
+
+ yield* Effect.scoped(
+ Effect.gen(function* () {
+ const updates = yield* DesktopUpdates.DesktopUpdates;
+ yield* updates.configure;
+
+ const checkFiber = yield* updates.check("poll").pipe(Effect.forkScoped);
+ yield* Deferred.await(checkStarted);
+ harness.emit("update-available", { version: "1.2.4" });
+ yield* flushCallbacks;
+
+ yield* TestClock.adjust(Duration.minutes(1));
+ assert.equal(harness.downloadCount(), 0);
+
+ yield* Deferred.succeed(releaseCheck, undefined);
+ yield* Fiber.join(checkFiber);
+ yield* Deferred.await(downloadStarted);
+ yield* TestClock.adjust(Duration.minutes(3));
+
+ assert.equal((yield* updates.getState).status, "downloading");
+ assert.isTrue(yield* updates.isActionActive);
+ assert.isFalse((yield* updates.check("manual")).checked);
+ assert.isFalse((yield* updates.download).accepted);
+ const channelError = yield* updates.setChannel("nightly").pipe(Effect.flip);
+ assert.equal(channelError._tag, "DesktopUpdateActionInProgressError");
+ if (channelError._tag === "DesktopUpdateActionInProgressError") {
+ assert.equal(channelError.action, "download");
+ }
+ assert.equal(harness.downloadCount(), 1);
+
+ harness.emit("update-downloaded", { version: "1.2.4" });
+ yield* Deferred.succeed(releaseDownload, undefined);
+ yield* flushCallbacks;
+
+ const state = yield* updates.getState;
+ assert.equal(state.status, "downloaded");
+ assert.equal(state.downloadedVersion, "1.2.4");
+ assert.isFalse(yield* updates.isActionActive);
+ }),
+ ).pipe(
+ Effect.ensuring(Deferred.succeed(releaseDownload, undefined)),
+ Effect.provide(Layer.merge(TestClock.layer(), harness.layer)),
+ );
+ }),
+ );
+
it.effect("preserves a queued installer when the feed has no update", () => {
const harness = makeHarness();
diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts
index 4c49f3c39d8fc..251c66ca9b71c 100644
--- a/apps/desktop/src/updates/DesktopUpdates.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.ts
@@ -520,19 +520,22 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
// update-available fires inside the check that found it, so the download
- // waits for that check to release the updater. Installing stays gated: the
- // app's server relaunches into it when idle, and quitting installs it.
+ // waits for that check to release the updater. Quitting installs the download.
const downloadInBackground = Effect.scoped(
Effect.gen(function* () {
const completions = yield* PubSub.subscribe(finishedUpdateActions);
while (Option.isSome(yield* activeUpdateAction)) {
yield* PubSub.take(completions);
}
- if (yield* Ref.get(desktopState.quitting)) return;
- yield* downloadAvailableUpdate;
}),
).pipe(
Effect.timeout(BACKGROUND_DOWNLOAD_START_WAIT),
+ Effect.andThen(
+ Effect.gen(function* () {
+ if (yield* Ref.get(desktopState.quitting)) return;
+ yield* downloadAvailableUpdate;
+ }),
+ ),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
From 4ff07873e093952aa6c59a374dfaf088c0d690b1 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:13:08 +0000
Subject: [PATCH 010/149] fix(dev): preserve lab state and feed origins across
restarts
---
scripts/update-lab.ts | 169 ++++++++++++++++++++++++++++--------------
1 file changed, 115 insertions(+), 54 deletions(-)
diff --git a/scripts/update-lab.ts b/scripts/update-lab.ts
index 5f70c4f84f9e7..29f8d91327581 100644
--- a/scripts/update-lab.ts
+++ b/scripts/update-lab.ts
@@ -191,18 +191,40 @@ function requireState(): LabState {
return state;
}
-/** Fake installers and the CLI write concurrently, so writes are atomic renames. */
+/** Keep the descriptor open so the kernel releases the lock even if this process dies. */
+function acquireLock(name: "state" | "processes"): () => void {
+ NodeFS.mkdirSync(NodePath.dirname(LAB), { recursive: true });
+ // Keep lock files outside LAB so reset cannot unlink a lock another process holds.
+ const fd = NodeFS.openSync(`${LAB}.${name}.lock`, "a");
+ try {
+ NodeChildProcess.execFileSync("flock", ["-x", "3"], {
+ stdio: ["ignore", "ignore", "inherit", fd],
+ });
+ } catch (error) {
+ NodeFS.closeSync(fd);
+ throw error;
+ }
+ return () => NodeFS.closeSync(fd);
+}
+
+/** Atomic replacement keeps readers from observing a partially written state. */
function writeState(state: LabState): void {
const temp = `${STATE_PATH}.${process.pid}.tmp`;
NodeFS.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`);
NodeFS.renameSync(temp, STATE_PATH);
}
-function updateState(mutate: (state: LabState) => void): LabState {
- const state = requireState();
- mutate(state);
- writeState(state);
- return state;
+function updateState(mutate: (state: LabState) => void, initial?: LabState): LabState {
+ const release = acquireLock("state");
+ try {
+ const state = readState() ?? initial ?? requireState();
+ mutate(state);
+ NodeFS.mkdirSync(LAB, { recursive: true });
+ writeState(state);
+ return state;
+ } finally {
+ release();
+ }
}
function requireEnv(state: LabState, id: string | undefined): LabEnv {
@@ -725,11 +747,16 @@ async function freePort(host: string, taken: Set): Promise {
fail("Could not find a free port.");
}
-/** True only for a live process whose command line is still one the lab started. */
-function isLabProcess(pid: number | null, marker: string): pid is number {
+/** Check the recorded process's command and working directory before signaling it. */
+function isLabProcess(pid: number | null, marker: string, cwd = REPO): pid is number {
if (!pid) return false;
try {
- return NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(marker);
+ const args = NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
+ return (
+ args.includes(marker) &&
+ args.includes(marker === "__feed" ? SELF : SERVER_BIN) &&
+ NodeFS.realpathSync(`/proc/${pid}/cwd`) === NodeFS.realpathSync(cwd)
+ );
} catch {
return false;
}
@@ -827,7 +854,7 @@ async function waitForPairingUrl(env: LabEnv): Promise {
const log = NodeFS.readFileSync(logPath(env.id), "utf8");
const url = /Pairing URL: (\S+)/.exec(log)?.[1];
if (url) return url;
- if (!isLabProcess(env.pid, serverMarker(env))) {
+ if (!isLabProcess(env.pid, serverMarker(env), projectDir(env.id))) {
fail(`${env.id} exited during startup; see ${logPath(env.id)}\n${log.slice(-2000)}`);
}
await new Promise((resolve) => setTimeout(resolve, 500));
@@ -836,11 +863,11 @@ async function waitForPairingUrl(env: LabEnv): Promise {
}
/** SIGTERM the process group we created, then SIGKILL it if it lingers. */
-async function stopGroup(pid: number, marker: string): Promise {
- if (!isLabProcess(pid, marker)) return;
+async function stopGroup(pid: number, marker: string, cwd = REPO): Promise {
+ if (!isLabProcess(pid, marker, cwd)) return;
process.kill(-pid, "SIGTERM");
const deadline = Date.now() + 15_000;
- while (Date.now() < deadline && isLabProcess(pid, marker)) {
+ while (Date.now() < deadline && isLabProcess(pid, marker, cwd)) {
await new Promise((resolve) => setTimeout(resolve, 250));
}
try {
@@ -950,7 +977,7 @@ async function up(args: string[]): Promise {
const envCount = Number(option(args, "envs") ?? previous?.envs.length ?? 3);
const launcherCount = Number(option(args, "launcher") ?? 1);
const [major, minor, patch] = REPO_VERSION.split(/[.-]/).map(Number);
- const state: LabState = previous ?? {
+ const initial: LabState = {
host,
feedPort: 0,
feedPid: null,
@@ -962,56 +989,76 @@ async function up(args: string[]): Promise {
) as LabState["latest"],
envs: [],
};
- state.host = host;
- state.busySeconds = Number(option(args, "busy-seconds") ?? state.busySeconds);
- state.fromVersion = option(args, "from-version") ?? state.fromVersion;
- NodeFS.mkdirSync(LAB, { recursive: true });
+ let state = updateState((next) => {
+ next.host = host;
+ next.busySeconds = Number(option(args, "busy-seconds") ?? next.busySeconds);
+ next.fromVersion = option(args, "from-version") ?? next.fromVersion;
+ for (let index = next.envs.length; index < envCount; index++) {
+ const row = INSTALL_MATRIX[index % INSTALL_MATRIX.length]!;
+ next.envs.push({
+ id: `env${index + 1}`,
+ port: 0,
+ pid: null,
+ launcher: index >= envCount - launcherCount,
+ projectId: null,
+ installs: Object.fromEntries(
+ PROVIDER_KEYS.map((key) => [
+ key,
+ { method: row[key], version: PROVIDERS[key].installed, failing: false },
+ ]),
+ ) as LabEnv["installs"],
+ });
+ }
+ }, initial);
const taken = new Set(state.envs.map((env) => env.port).concat(state.feedPort));
if (!isLabProcess(state.feedPid, "__feed")) {
- state.feedPort = await freePort("127.0.0.1", taken);
- state.feedPid = spawnDetached(NODE, [SELF, "__feed", String(state.feedPort)], {
+ const feedPort =
+ state.feedPort > 0 && !listeningPorts().has(state.feedPort)
+ ? state.feedPort
+ : await freePort("127.0.0.1", taken);
+ // Running servers retain the old feed origin in their environment until restarted.
+ if (feedPort !== state.feedPort) {
+ for (const env of state.envs) {
+ if (env.pid) await stopGroup(env.pid, serverMarker(env), projectDir(env.id));
+ state = updateState((next) => {
+ requireEnv(next, env.id).pid = null;
+ });
+ }
+ }
+ const feedPid = spawnDetached(NODE, [SELF, "__feed", String(feedPort)], {
env: { PATH: process.env.PATH },
cwd: REPO,
log: NodePath.join(LAB, "feed.log"),
});
+ state = updateState((next) => {
+ next.feedPort = feedPort;
+ next.feedPid = feedPid;
+ });
}
// The feed always offers this checkout's version, the update a launcher env is behind on.
if (!publishedRuntimes().includes(REPO_VERSION)) buildRuntime(REPO_VERSION, undefined);
- for (let index = state.envs.length; index < envCount; index++) {
- const row = INSTALL_MATRIX[index % INSTALL_MATRIX.length]!;
- state.envs.push({
- id: `env${index + 1}`,
- port: 0,
- pid: null,
- launcher: index >= envCount - launcherCount,
- projectId: null,
- installs: Object.fromEntries(
- PROVIDER_KEYS.map((key) => [
- key,
- { method: row[key], version: PROVIDERS[key].installed, failing: false },
- ]),
- ) as LabEnv["installs"],
- });
- }
- writeState(state);
-
const started: LabEnv[] = [];
for (const env of state.envs) {
- if (isLabProcess(env.pid, serverMarker(env))) continue;
+ if (isLabProcess(env.pid, serverMarker(env), projectDir(env.id))) continue;
seedEnvFiles(state, env);
- if (!env.port || listeningPorts().has(env.port)) env.port = await freePort(host, taken);
- env.pid = startServer(state, env);
- started.push(env);
- writeState(state);
+ const port =
+ !env.port || listeningPorts().has(env.port) ? await freePort(host, taken) : env.port;
+ const pid = startServer(state, { ...env, port });
+ state = updateState((next) => {
+ const current = requireEnv(next, env.id);
+ current.port = port;
+ current.pid = pid;
+ });
+ started.push(requireEnv(state, env.id));
}
console.log(
`feed http://127.0.0.1:${state.feedPort} (npm registry /npm, releases /releases)`,
);
const urls = await Promise.all(started.map(waitForPairingUrl));
for (const env of state.envs) {
- const index = started.indexOf(env);
+ const index = started.findIndex((candidate) => candidate.id === env.id);
console.log(`${env.id.padEnd(9)} ${originOf(state, env)} pid ${env.pid}${launcherLabel(env)}`);
console.log(` ${index >= 0 ? urls[index] : "(already running; `pair` mints a URL)"}`);
}
@@ -1021,12 +1068,15 @@ async function down(): Promise {
const state = readState();
if (!state) return;
for (const env of state.envs) {
- if (env.pid) await stopGroup(env.pid, serverMarker(env));
- env.pid = null;
+ if (env.pid) await stopGroup(env.pid, serverMarker(env), projectDir(env.id));
+ updateState((next) => {
+ requireEnv(next, env.id).pid = null;
+ });
}
if (state.feedPid) await stopGroup(state.feedPid, "__feed");
- state.feedPid = null;
- writeState(state);
+ updateState((next) => {
+ next.feedPid = null;
+ });
console.log("Lab stopped.");
}
@@ -1037,7 +1087,7 @@ function status(): void {
`feed http://127.0.0.1:${state.feedPort} ${feed}; delay ${state.delayMs}ms; runtimes ${publishedRuntimes().join(", ") || "-"}`,
);
for (const env of state.envs) {
- const alive = isLabProcess(env.pid, serverMarker(env));
+ const alive = isLabProcess(env.pid, serverMarker(env), projectDir(env.id));
console.log(
`${env.id} ${originOf(state, env)} pid ${env.pid ?? "-"} ${alive ? "up" : "down"}${launcherLabel(env)}`,
);
@@ -1182,11 +1232,16 @@ async function main(argv: string[]): Promise {
return void console.log(`Published ${buildRuntime(args[0], option(args, "archive"))}`);
}
case "reset": {
- const state = readState();
- if (state && [state.feedPid, ...state.envs.map((e) => e.pid)].some((pid) => pid !== null)) {
- fail("Run `down` first.");
+ const release = acquireLock("state");
+ try {
+ const state = readState();
+ if (state && [state.feedPid, ...state.envs.map((e) => e.pid)].some((pid) => pid !== null)) {
+ fail("Run `down` first.");
+ }
+ NodeFS.rmSync(LAB, { recursive: true, force: true });
+ } finally {
+ release();
}
- NodeFS.rmSync(LAB, { recursive: true, force: true });
return void console.log(`Removed ${LAB}.`);
}
default:
@@ -1195,4 +1250,10 @@ async function main(argv: string[]): Promise {
}
}
-await main(process.argv.slice(2));
+const argv = process.argv.slice(2);
+const release = ["up", "down", "reset"].includes(argv[0] ?? "") ? acquireLock("processes") : null;
+try {
+ await main(argv);
+} finally {
+ release?.();
+}
From bd670c71c75cc158fe17f8002a2dc92ec0cd2535 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:19:25 +0000
Subject: [PATCH 011/149] fix(server): wait for background installs before
provider startup
---
.../ProviderTurnStartService.ts | 8 +++++++-
.../providerMaintenanceCommandCoordinator.ts | 19 +++++++++++++++++++
apps/server/src/updates/UpdateWindow.ts | 10 ++++++----
3 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
index a928680b24024..1d54dffaea777 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
@@ -25,6 +25,7 @@ import * as Schema from "effect/Schema";
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
+import * as ProviderMaintenanceCoordinator from "../provider/providerMaintenanceCommandCoordinator.ts";
import * as EventSink from "./EventSink.ts";
import * as ContextHandoffService from "./ContextHandoffService.ts";
import {
@@ -116,6 +117,7 @@ export const layer: Layer.Layer<
const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
const runExecution = yield* RunExecutionService.RunExecutionServiceV2;
const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2;
+ const admission = yield* ProviderMaintenanceCoordinator.ProviderMaintenanceAdmission;
// These callbacks outlive startup while a run drains background work. Build
// them outside start's scope so they cannot retain its full thread history.
@@ -226,6 +228,10 @@ export const layer: Layer.Layer<
readonly willRetry?: boolean;
}) {
const { runId } = input;
+ // The run is durably starting before this effect is dispatched. Wait for
+ // an admitted install, then reread: active-work blocks later installs,
+ // without holding the permit through provider commands such as /compact.
+ yield* admission.withPermit(Effect.void);
const projection = yield* projectionStore.getTurnStartContext(input.threadId, runId);
const run = projection.runs.find((candidate) => candidate.id === runId);
if (run === undefined) {
@@ -1291,4 +1297,4 @@ export const layer: Layer.Layer<
),
});
}),
-);
+).pipe(Layer.provide(ProviderMaintenanceCoordinator.admissionLayer));
diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
index f2fc03659be0c..f544028735512 100644
--- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
+++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
@@ -1,6 +1,25 @@
import * as KeyedLock from "@t3tools/shared/KeyedLock";
+import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
+import * as Semaphore from "effect/Semaphore";
+
+/** Shares background install admission with provider startup, before either touches a CLI. */
+export class ProviderMaintenanceAdmission extends Context.Service<
+ ProviderMaintenanceAdmission,
+ {
+ readonly withPermit: (effect: Effect.Effect) => Effect.Effect;
+ }
+>()("t3/provider/ProviderMaintenanceAdmission") {}
+
+// Keep one layer reference so the update window and turn startup share its permit.
+export const admissionLayer = Layer.effect(
+ ProviderMaintenanceAdmission,
+ Effect.map(Semaphore.make(1), (permit) =>
+ ProviderMaintenanceAdmission.of({ withPermit: permit.withPermits(1) }),
+ ),
+);
export interface ProviderMaintenanceCommandCoordinatorShape {
readonly withCommandLock: (input: {
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index 023794a69e900..680cbf4ebfa36 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -6,10 +6,10 @@ import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
-import * as Semaphore from "effect/Semaphore";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
+import * as ProviderMaintenanceCoordinator from "../provider/providerMaintenanceCommandCoordinator.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as ServerSettings from "../serverSettings.ts";
@@ -95,6 +95,7 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
const serverSettings = yield* ServerSettings.ServerSettingsService;
const providers = yield* ProviderRegistry.ProviderRegistry;
const timings = yield* updateTimings;
+ const admission = yield* ProviderMaintenanceCoordinator.ProviderMaintenanceAdmission;
const check: UpdateWindow["Service"]["check"] = Effect.fn("updates.UpdateWindow.check")(
function* (options) {
@@ -213,9 +214,8 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
},
);
- const installLock = yield* Semaphore.make(1);
const runIfOpen: UpdateWindow["Service"]["runIfOpen"] = (options, install) =>
- installLock.withPermits(1)(
+ admission.withPermit(
check(options).pipe(
Effect.flatMap((status) => (status.open ? Effect.asSome(install) : Effect.succeedNone)),
),
@@ -232,4 +232,6 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
return UpdateWindow.of({ check, runIfOpen, runWhenOpen });
});
-export const layer = Layer.effect(UpdateWindow, make());
+export const layer = Layer.effect(UpdateWindow, make()).pipe(
+ Layer.provide(ProviderMaintenanceCoordinator.admissionLayer),
+);
From dedb32e66993fb8c7c2db20a7d7841db30936570 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:19:26 +0000
Subject: [PATCH 012/149] fix(server): hold startup admission through launcher
handoff
---
apps/server/src/updates/ServerAutoUpdater.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/server/src/updates/ServerAutoUpdater.ts b/apps/server/src/updates/ServerAutoUpdater.ts
index 6b23b5555348c..43c960dec60c9 100644
--- a/apps/server/src/updates/ServerAutoUpdater.ts
+++ b/apps/server/src/updates/ServerAutoUpdater.ts
@@ -102,6 +102,9 @@ export const layer = Layer.effectDiscard(
skippedTargets.add(targetVersion);
yield* Effect.logInfo("Installing a background server update", { targetVersion });
yield* selfUpdate.update({ targetVersion });
+ // The launcher stops us after accepting the handoff. Keep new provider
+ // starts behind the install permit until this runtime is shut down.
+ yield* Effect.never;
}),
);
}).pipe(
From 82a96439c3d5c7d08ef05856462060f3d7e3b676 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:26:46 +0000
Subject: [PATCH 013/149] fix(server): serialize manual installs with
background updates
---
.../providerMaintenanceCommandCoordinator.ts | 4 +-
.../src/provider/providerMaintenanceRunner.ts | 24 +++++++--
apps/server/src/serverSettings.test.ts | 51 +++++++++++++------
apps/server/src/serverSettings.ts | 8 ++-
.../server/src/updates/ProviderAutoUpdater.ts | 23 +++++++--
apps/server/src/updates/UpdateWindow.ts | 42 +++++++--------
6 files changed, 106 insertions(+), 46 deletions(-)
diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
index f544028735512..3dec5a7981291 100644
--- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
+++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
@@ -26,6 +26,7 @@ export interface ProviderMaintenanceCommandCoordinatorShape {
readonly targetKey: string;
readonly lockKey: string;
readonly onQueued?: Effect.Effect;
+ readonly admit?: (effect: Effect.Effect) => Effect.Effect;
readonly run: Effect.Effect;
}) => Effect.Effect;
}
@@ -58,6 +59,7 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn(
targetKey,
lockKey,
onQueued,
+ admit,
run,
}) =>
Effect.gen(function* () {
@@ -67,7 +69,7 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn(
}
return yield* (onQueued ?? Effect.void).pipe(
- Effect.andThen(locks.withLock(lockKey, run)),
+ Effect.andThen(admit ? admit(locks.withLock(lockKey, run)) : locks.withLock(lockKey, run)),
Effect.ensuring(releaseTarget(targetKey)),
);
});
diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts
index 5ef57660e01fe..109a554a6cb1e 100644
--- a/apps/server/src/provider/providerMaintenanceRunner.ts
+++ b/apps/server/src/provider/providerMaintenanceRunner.ts
@@ -27,7 +27,11 @@ import * as NodeUtil from "node:util";
import * as ModelManifest from "./ModelManifest.ts";
import { resolveProviderCompatibility } from "./providerCompatibility.ts";
import * as ProviderRegistry from "./ProviderRegistry.ts";
-import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
+import {
+ admissionLayer,
+ makeProviderMaintenanceCommandCoordinator,
+ ProviderMaintenanceAdmission,
+} from "./providerMaintenanceCommandCoordinator.ts";
import {
enrichProviderSnapshotWithVersionAdvisory,
makeTargetedProviderUpdateAction,
@@ -67,6 +71,8 @@ export interface ProviderMaintenanceRunnerShape {
readonly targetVersion?: string | undefined;
},
) => Effect.Effect;
+ /** Only for callers already holding the background update admission permit. */
+ readonly updateProviderWhileAdmitted: ProviderMaintenanceRunnerShape["updateProvider"];
}
export class ProviderMaintenanceRunner extends Context.Service<
@@ -292,6 +298,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const versionCache = yield* ProviderVersionCache;
+ const admission = yield* ProviderMaintenanceAdmission;
const runMaintenanceCommand = (
update: ProviderMaintenanceCommandAction,
onProgress: (line: string) => Effect.Effect,
@@ -381,9 +388,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
}),
);
- const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn(
- "ProviderMaintenanceRunner.updateProvider",
- )(function* (target) {
+ const updateProvider = Effect.fn("ProviderMaintenanceRunner.updateProvider")(function* (
+ target: Parameters[0],
+ alreadyAdmitted = false,
+ ) {
const provider = typeof target === "string" ? target : target.provider;
const instanceId =
typeof target === "string"
@@ -578,6 +586,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
targetKey,
lockKey: update.lockKey,
onQueued: setQueuedState,
+ // Admit before the installer lock: a manual update must not hold that
+ // lock while a background pass holding admission waits for it.
+ ...(alreadyAdmitted ? {} : { admit: admission.withPermit }),
run: runProviderUpdate(),
})
.pipe(
@@ -594,7 +605,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
return ProviderMaintenanceRunner.of({
updateProvider,
+ updateProviderWhileAdmitted: (target) => updateProvider(target, true),
});
});
-export const layer = Layer.effect(ProviderMaintenanceRunner, make());
+export const layer = Layer.effect(ProviderMaintenanceRunner, make()).pipe(
+ Layer.provide(admissionLayer),
+);
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index 4444a7851dffc..469adc22c87fd 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -1030,23 +1030,44 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(layerServerSettings())),
);
- it.effect("migrates legacy providers from an invalid file without rewriting it", () =>
- Effect.gen(function* () {
- const serverConfig = yield* ServerConfig.ServerConfig;
- const fileSystem = yield* FileSystem.FileSystem;
- const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
- const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}';
- yield* fileSystem.writeFileString(serverConfig.settingsPath, raw);
+ it.effect("disables automatic recovery and updates without rewriting an invalid file", () =>
+ Effect.forEach(
+ [
+ {
+ raw: '{"automaticUpdates":false,"continueThreadsAfterServerUpdate":false,"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}',
+ migratedProvider: {
+ driver: ProviderDriverKind.make("codex"),
+ config: { binaryPath: "/x" },
+ },
+ },
+ {
+ raw: '{"automaticUpdates":true,"continueThreadsAfterServerUpdate":true,"providers":42}',
+ migratedProvider: undefined,
+ },
+ {
+ raw: '{"automaticUpdates":false,"continueThreadsAfterServerUpdate":false',
+ migratedProvider: undefined,
+ },
+ ],
+ ({ raw, migratedProvider }) =>
+ Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig.ServerConfig;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ yield* fileSystem.writeFileString(serverConfig.settingsPath, raw);
- const settings = yield* serverSettings.getSettings;
+ const settings = yield* serverSettings.getSettings;
- // The readable provider settings still apply, but the file stays for the user to repair.
- assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], {
- driver: ProviderDriverKind.make("codex"),
- config: { binaryPath: "/x" },
- });
- assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw);
- }).pipe(Effect.provide(layerServerSettings())),
+ // Readable legacy provider settings still apply while the file stays for repair.
+ assert.deepEqual(
+ settings.providerInstances[ProviderInstanceId.make("codex")],
+ migratedProvider,
+ );
+ assert.isFalse(settings.automaticUpdates);
+ assert.isFalse(settings.continueThreadsAfterServerUpdate);
+ assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw);
+ }).pipe(Effect.provide(layerServerSettings())),
+ ),
);
it.effect("skips a disabled provider instance when picking the text generation fallback", () =>
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index 7536a74d6e02c..d05ddeafbc739 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -786,8 +786,14 @@ const make = Effect.gen(function* () {
if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
const failure = decoded._tag === "Failure" ? decoded : legacySettings;
settingsFileTrusted = false;
+ // Unreadable persisted choices cannot authorize automatic updates or recovery.
+ settings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ automaticUpdates: false,
+ continueThreadsAfterServerUpdate: false,
+ };
if (failure._tag === "Failure") {
- yield* Effect.logWarning("failed to parse settings.json, using defaults", {
+ yield* Effect.logWarning("failed to parse settings.json, using safe defaults", {
path: settingsPath,
issues: Cause.pretty(failure.cause),
cause: failure.cause,
diff --git a/apps/server/src/updates/ProviderAutoUpdater.ts b/apps/server/src/updates/ProviderAutoUpdater.ts
index 2c0c38a54b670..c6350733b69cf 100644
--- a/apps/server/src/updates/ProviderAutoUpdater.ts
+++ b/apps/server/src/updates/ProviderAutoUpdater.ts
@@ -58,16 +58,31 @@ export const layer = Layer.effectDiscard(
return attemptedAt === undefined || nowMs - attemptedAt >= Duration.toMillis(RETRY_AFTER);
});
for (const provider of candidates) {
- const targetVersion = provider.versionAdvisory?.latestVersion;
const install = Effect.gen(function* () {
- attempts.set(`${provider.instanceId}@${targetVersion}`, yield* Clock.currentTimeMillis);
+ const currentSettings = yield* serverSettings.getSettings;
+ if (!currentSettings.automaticUpdates || !currentSettings.enableProviderUpdateChecks)
+ return;
+ const current = (yield* providers.getProviders).find(
+ (candidate) => candidate.instanceId === provider.instanceId,
+ );
+ if (!current || !isAutoUpdatable(current)) return;
+ const targetVersion = current.versionAdvisory?.latestVersion;
+ const attemptKey = `${current.instanceId}@${targetVersion}`;
+ const nowMs = yield* Clock.currentTimeMillis;
+ const attemptedAt = attempts.get(attemptKey);
+ if (attemptedAt !== undefined && nowMs - attemptedAt < Duration.toMillis(RETRY_AFTER))
+ return;
+ attempts.set(attemptKey, nowMs);
yield* Effect.logInfo("Updating provider in the background", {
instanceId: provider.instanceId,
- fromVersion: provider.version,
+ fromVersion: current.version,
targetVersion,
});
yield* runner
- .updateProvider({ provider: provider.driver, instanceId: provider.instanceId })
+ .updateProviderWhileAdmitted({
+ provider: current.driver,
+ instanceId: current.instanceId,
+ })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Background provider update failed", {
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index 680cbf4ebfa36..430baea17c4f1 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -187,26 +187,28 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
if (Option.isNone(settings) || !settings.value.automaticUpdates) {
return { open: false, blockers };
}
- if (settings.value.autoResumeLimitedThreads) {
- const limited = yield* projections
- .getLimitRecoveryCandidates({ now: horizon, autoResume: true, snooze: false })
- .pipe(
- Effect.tapError((cause) =>
- Effect.logWarning("Update window could not read usage-limit resumes", { cause }),
- ),
- Effect.option,
- );
- if (Option.isNone(limited)) return { open: false, blockers };
- for (const thread of limited.value) {
- if (thread.limitRecovery?.autoResume !== true) continue;
- const resumesAt = DateTime.make(thread.limitRecovery.resetAt);
- if (Option.isSome(resumesAt)) {
- blockers.push({
- type: "usage-limit-resume",
- threadId: thread.id,
- resumesAt: resumesAt.value,
- });
- }
+ const limited = yield* projections
+ .getLimitRecoveryCandidates({
+ now: horizon,
+ autoResume: settings.value.autoResumeLimitedThreads,
+ snooze: false,
+ })
+ .pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read usage-limit resumes", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(limited)) return { open: false, blockers };
+ for (const thread of limited.value) {
+ if (thread.usageLimitResetAt === null || thread.usageLimitResetAt === undefined) continue;
+ const resumesAt = DateTime.make(thread.usageLimitResetAt);
+ if (Option.isSome(resumesAt) && !DateTime.isGreaterThan(resumesAt.value, horizon)) {
+ blockers.push({
+ type: "usage-limit-resume",
+ threadId: thread.id,
+ resumesAt: resumesAt.value,
+ });
}
}
From 4ce604c83f881cb2e6e52d90fb806e1b8800daef Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:31:29 +0000
Subject: [PATCH 014/149] fix(server): protect terminal commands during
automatic updates
---
apps/server/src/terminal/Manager.test.ts | 156 +++++++++++++++++++
apps/server/src/terminal/Manager.ts | 128 ++++++++++++---
apps/server/src/updates/ServerAutoUpdater.ts | 2 +-
apps/server/src/updates/UpdateWindow.ts | 20 ++-
docs/user/updating.md | 2 +
5 files changed, 277 insertions(+), 31 deletions(-)
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index 5a40f7bbb9334..75d6e8a949e2f 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -1297,7 +1297,15 @@ it.layer(
yield* manager.open(openInput({ terminalId: "subshell" }));
yield* manager.open(openInput({ threadId: "thread-2" }));
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
+ false,
+ false,
+ false,
+ false,
+ ]);
yield* manager.closeIdle({ threadId: "thread-1" });
+ assert.equal(yield* manager.hasBusyTerminals, true);
expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
true,
@@ -1342,6 +1350,154 @@ it.layer(
}),
);
+ it.effect("checks fresh work across threads without changing idle shells or metadata", () =>
+ Effect.gen(function* () {
+ let snapshotCalls = 0;
+ const shells = [
+ { pid: 9000, ppid: 1, name: "zsh" },
+ { pid: 100, ppid: 9000, name: "zsh" },
+ { pid: 9001, ppid: 1, name: "zsh" },
+ ];
+ let processes = shells;
+ const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
+ subprocessPollIntervalMs: 60_000,
+ processTable: Effect.sync(() => {
+ snapshotCalls += 1;
+ return processes;
+ }),
+ }).pipe(Effect.provide(layerWithHostPlatform("linux")));
+
+ assert.equal(yield* manager.hasBusyTerminals, false);
+ assert.equal(snapshotCalls, 0);
+ yield* manager.open(openInput());
+ yield* manager.open(openInput({ threadId: "thread-2" }));
+ const events = yield* getEvents;
+ assert.equal(yield* manager.hasBusyTerminals, false);
+ assert.equal(snapshotCalls, 1);
+
+ processes = [...shells, { pid: 200, ppid: 9001, name: "node" }];
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ processes = [
+ ...shells,
+ { pid: 200, ppid: 9001, name: "zsh" },
+ { pid: 201, ppid: 200, name: "sleep" },
+ ];
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ processes = shells;
+ assert.equal(yield* manager.hasBusyTerminals, false);
+ expect(yield* getEvents).toEqual(events);
+ expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]);
+ }),
+ );
+
+ it.effect("treats failed, truncated, timed out, and missing-shell inspections as busy", () =>
+ Effect.gen(function* () {
+ let stdout = "9000 1 zsh";
+ let code = 0;
+ let timedOut = false;
+ let stdoutTruncated = false;
+ const processRunner: ProcessRunner.ProcessRunner["Service"] = {
+ run: () =>
+ Effect.sync(() => ({
+ stdout,
+ stderr: "",
+ code: ChildProcessSpawner.ExitCode(code),
+ timedOut,
+ stdoutTruncated,
+ stderrTruncated: false,
+ stdoutInvalidUtf8: false,
+ stderrInvalidUtf8: false,
+ })),
+ };
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ subprocessPollIntervalMs: 60_000,
+ }).pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
+ Effect.provide(layerWithHostPlatform("linux")),
+ );
+ yield* manager.open(openInput());
+ assert.equal(yield* manager.hasBusyTerminals, false);
+ code = 1;
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ code = 0;
+ stdoutTruncated = true;
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ stdoutTruncated = false;
+ timedOut = true;
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ timedOut = false;
+ stdout = "";
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ stdout = "9000 1 zsh";
+ assert.equal(yield* manager.hasBusyTerminals, false);
+ expect(ptyAdapter.processes[0]!.killed).toBe(false);
+ }),
+ );
+
+ it.effect("treats input, output, and new terminals during inspection as busy", () =>
+ Effect.gen(function* () {
+ let duringCheck: Effect.Effect = Effect.void;
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ subprocessPollIntervalMs: 60_000,
+ subprocessInspector: () =>
+ duringCheck.pipe(
+ Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
+ ),
+ });
+ yield* manager.open(openInput());
+ assert.equal(yield* manager.hasBusyTerminals, false);
+ duringCheck = manager
+ .write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "make build\r" })
+ .pipe(Effect.orDie);
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ duringCheck = Effect.sync(() => ptyAdapter.processes[0]!.emitData("make build\r\n"));
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ duringCheck = manager
+ .open(openInput({ threadId: "thread-2" }))
+ .pipe(Effect.asVoid, Effect.orDie);
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]);
+ }),
+ );
+
+ it.effect("holds terminal launches and writes behind a server restart permit", () =>
+ Effect.gen(function* () {
+ const { manager, ptyAdapter } = yield* createManager(5, {
+ subprocessPollIntervalMs: 60_000,
+ subprocessInspector: () =>
+ Effect.succeed({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
+ });
+ yield* manager.open(openInput());
+ const held = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ const restartWindow = yield* manager
+ .withRestartPermit(
+ Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(release))),
+ )
+ .pipe(Effect.forkScoped);
+ yield* Deferred.await(held);
+ assert.equal(yield* manager.hasBusyTerminals, false);
+
+ const write = yield* manager
+ .write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "make build\r" })
+ .pipe(Effect.forkScoped);
+ const open = yield* manager.open(openInput({ threadId: "thread-2" })).pipe(Effect.forkScoped);
+ const restart = yield* manager.restart(restartInput()).pipe(Effect.forkScoped);
+ yield* Effect.yieldNow;
+ expect(ptyAdapter.spawnInputs).toHaveLength(1);
+ expect(ptyAdapter.processes[0]!.writes).toEqual([]);
+ expect(ptyAdapter.processes[0]!.killed).toBe(false);
+
+ yield* Deferred.succeed(release, undefined);
+ yield* Fiber.join(restartWindow);
+ yield* Fiber.join(write);
+ yield* Fiber.join(open);
+ yield* Fiber.join(restart);
+ expect(ptyAdapter.spawnInputs).toHaveLength(3);
+ expect(ptyAdapter.processes.flatMap((process) => process.writes)).toEqual(["make build\r"]);
+ }),
+ );
+
it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>
Effect.gen(function* () {
const fallbackCalls: Array = [];
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index 562307477dcf7..ee294307a243f 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -60,6 +60,7 @@ import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
+import * as Semaphore from "effect/Semaphore";
import * as SynchronizedRef from "effect/SynchronizedRef";
import * as ServerConfig from "../config.ts";
@@ -219,6 +220,15 @@ export class TerminalManager extends Context.Service<
readonly terminalId?: string;
}) => Effect.Effect;
+ /** Inspect current terminal work without changing sessions. Unknown activity is busy. */
+ readonly hasBusyTerminals: Effect.Effect;
+
+ /**
+ * Hold terminal launches and input while checking idle state and handing off a server restart.
+ * Read-only activity checks stay available; do not call open, restart, or write inside this permit.
+ */
+ readonly withRestartPermit: (effect: Effect.Effect) => Effect.Effect;
+
/**
* Subscribe to terminal runtime events with a direct callback.
*
@@ -243,6 +253,8 @@ interface TerminalSubprocessInspectResult {
readonly hasRunningSubprocess: boolean;
readonly childCommand: string | null;
readonly processIds: ReadonlyArray;
+ /** False when an otherwise successful process snapshot omitted the shell. */
+ readonly terminalExists?: boolean;
}
interface TerminalSubprocessInspector {
@@ -727,7 +739,12 @@ function deriveSubprocessInspectResult(
(snapshot.childrenByParent.get(pid)?.length ?? 0) > 0,
);
if (childPid === undefined) {
- return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
+ return {
+ hasRunningSubprocess: false,
+ childCommand: null,
+ processIds: [],
+ terminalExists: snapshot.commandById.has(terminalPid),
+ };
}
const processIds = new Set([terminalPid]);
const pending = [terminalPid];
@@ -745,6 +762,7 @@ function deriveSubprocessInspectResult(
hasRunningSubprocess: true,
childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
processIds: [...processIds],
+ terminalExists: snapshot.commandById.has(terminalPid),
};
}
@@ -1577,6 +1595,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
killFibers: new Map(),
});
const threadLocks = yield* KeyedLock.make();
+ const terminalAdmission = yield* Semaphore.make(1);
+ const withRestartPermit = terminalAdmission.withPermits(1);
const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>();
const workerScope = yield* Scope.make("sequential");
yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void));
@@ -2701,9 +2721,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
});
const openLocked = (input: TerminalOpenInput) =>
- withWorkspaceLease(
- path.resolve(input.worktreePath ?? input.cwd),
- openWithWorkspaceLease(input),
+ withRestartPermit(
+ withWorkspaceLease(
+ path.resolve(input.worktreePath ?? input.cwd),
+ openWithWorkspaceLease(input),
+ ),
);
const open: TerminalManager["Service"]["open"] = (input) =>
@@ -2950,29 +2972,34 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
);
};
- const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) {
- const terminalId = input.terminalId;
- const session = yield* requireSession(input.threadId, terminalId);
- const process = session.process;
- if (!process || session.status !== "running") {
- if (session.status === "exited") return;
- return yield* new TerminalNotRunningError({
- threadId: input.threadId,
- terminalId,
- });
- }
- session.inputCount += 1;
- yield* Effect.try({
- try: () => process.write(input.data),
- catch: (cause) =>
- new TerminalWriteError({
+ const writeUnlocked: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(
+ function* (input) {
+ const terminalId = input.terminalId;
+ const session = yield* requireSession(input.threadId, terminalId);
+ const process = session.process;
+ if (!process || session.status !== "running") {
+ if (session.status === "exited") return;
+ return yield* new TerminalNotRunningError({
threadId: input.threadId,
terminalId,
- terminalPid: process.pid,
- cause,
- }),
- });
- });
+ });
+ }
+ session.inputCount += 1;
+ yield* Effect.try({
+ try: () => process.write(input.data),
+ catch: (cause) =>
+ new TerminalWriteError({
+ threadId: input.threadId,
+ terminalId,
+ terminalPid: process.pid,
+ cause,
+ }),
+ });
+ },
+ );
+
+ const write: TerminalManager["Service"]["write"] = (input) =>
+ withRestartPermit(writeUnlocked(input));
const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) {
const session = yield* getSession(input.threadId, input.terminalId);
@@ -3103,6 +3130,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
restartResolved(resolved),
),
),
+ withRestartPermit,
),
);
@@ -3174,6 +3202,54 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
),
);
+ const hasBusyTerminals: TerminalManager["Service"]["hasBusyTerminals"] = Effect.gen(function* () {
+ const state = yield* readManagerState;
+ const running = new Map(
+ [...state.sessions]
+ .filter(([, session]) => session.status === "starting" || session.status === "running")
+ .map(
+ ([key, session]) =>
+ [
+ key,
+ {
+ status: session.status,
+ pid: session.pid,
+ process: session.process,
+ activityMark: session.eventSequence + session.inputCount,
+ },
+ ] as const,
+ ),
+ );
+ if (running.size === 0) return false;
+ if ([...running.values()].some((session) => session.status === "starting")) return true;
+ const { inspector } = yield* acquireSubprocessInspector;
+ for (const session of running.values()) {
+ if (session.pid === null || !Number.isInteger(session.pid) || session.pid <= 0) return true;
+ const result = yield* inspector(session.pid);
+ if (result.hasRunningSubprocess || result.terminalExists === false) return true;
+ }
+ // Input, output, or a new process can arrive after the shared snapshot.
+ const latest = yield* readManagerState;
+ return [...latest.sessions].some(([key, session]) => {
+ if (session.status === "starting") return true;
+ if (session.status !== "running") return false;
+ const inspected = running.get(key);
+ return (
+ inspected === undefined ||
+ inspected.pid !== session.pid ||
+ inspected.process !== session.process ||
+ inspected.activityMark !== session.eventSequence + session.inputCount ||
+ session.pendingProcessEvents.length > session.pendingProcessEventIndex
+ );
+ });
+ }).pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("failed to inspect busy terminals", { error: error.message }).pipe(
+ Effect.as(true),
+ ),
+ ),
+ );
+
return TerminalManager.of({
open,
attachStream,
@@ -3184,6 +3260,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
restart,
close,
closeIdle,
+ hasBusyTerminals,
+ withRestartPermit,
subscribe,
subscribeMetadata,
});
diff --git a/apps/server/src/updates/ServerAutoUpdater.ts b/apps/server/src/updates/ServerAutoUpdater.ts
index 43c960dec60c9..946a489d4e5da 100644
--- a/apps/server/src/updates/ServerAutoUpdater.ts
+++ b/apps/server/src/updates/ServerAutoUpdater.ts
@@ -94,7 +94,7 @@ export const layer = Layer.effectDiscard(
const targetVersion = pendingTarget;
if (targetVersion === undefined) return;
yield* updateWindow.runIfOpen(
- { closesWindows: false },
+ { closesWindows: false, restartsServer: true },
Effect.gen(function* () {
// The setting may have been turned off while waiting.
if (!(yield* automaticUpdatesEnabled)) return;
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index 9fd06ed2b37d4..51ae4778a800c 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -13,6 +13,7 @@ import * as ProviderMaintenanceCoordinator from "../provider/providerMaintenance
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as ServerSettings from "../serverSettings.ts";
+import * as TerminalManager from "../terminal/Manager.ts";
/** Work due this soon keeps the window closed, so it does not start late. */
const UPDATE_LOOKAHEAD = Duration.minutes(5);
@@ -43,6 +44,7 @@ export type UpdateWindowBlocker =
| { readonly type: "recent-interaction"; readonly quietAt: DateTime.Utc }
| { readonly type: "focused-window" }
| { readonly type: "provider-update" }
+ | { readonly type: "terminal-work" }
| {
readonly type: "scheduled-task";
readonly taskId: ScheduledTaskId;
@@ -59,6 +61,8 @@ export type UpdateWindowBlocker =
export interface UpdateWindowOptions {
/** The update closes the app windows (a desktop install), not just a reconnect. */
readonly closesWindows: boolean;
+ /** Server shutdown also terminates integrated terminals. */
+ readonly restartsServer?: boolean;
}
export interface UpdateWindowStatus {
@@ -98,6 +102,7 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
const providers = yield* ProviderRegistry.ProviderRegistry;
const timings = yield* updateTimings;
const admission = yield* ProviderMaintenanceCoordinator.ProviderMaintenanceAdmission;
+ const terminals = yield* TerminalManager.TerminalManager;
const check: UpdateWindow["Service"]["check"] = Effect.fn("updates.UpdateWindow.check")(
function* (options) {
@@ -127,6 +132,9 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
if (providerUpdating) {
blockers.push({ type: "provider-update" });
}
+ if (options.restartsServer && (yield* terminals.hasBusyTerminals)) {
+ blockers.push({ type: "terminal-work" });
+ }
const lastInteraction = yield* backgroundPolicy.lastClientInteractionAt;
if (Option.isSome(lastInteraction)) {
@@ -218,12 +226,14 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
},
);
- const runIfOpen: UpdateWindow["Service"]["runIfOpen"] = (options, install) =>
- admission.withPermit(
- check(options).pipe(
- Effect.flatMap((status) => (status.open ? Effect.asSome(install) : Effect.succeedNone)),
- ),
+ const runIfOpen: UpdateWindow["Service"]["runIfOpen"] = (options, install) => {
+ const attempt = check(options).pipe(
+ Effect.flatMap((status) => (status.open ? Effect.asSome(install) : Effect.succeedNone)),
+ );
+ return admission.withPermit(
+ options.restartsServer ? terminals.withRestartPermit(attempt) : attempt,
);
+ };
const runWhenOpen: UpdateWindow["Service"]["runWhenOpen"] = (options, install) =>
Effect.gen(function* () {
while (true) {
diff --git a/docs/user/updating.md b/docs/user/updating.md
index c1c566d3b758f..cb0de606f90c2 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -83,6 +83,8 @@ Then it installs:
- the newest T3 Code release on its channel, when it runs as a background service
(`t3 service install`). It downloads first and restarts only inside that window.
+Background services also wait for integrated terminal commands to finish.
+
Desktop updates download in the background and install when you quit the app.
The app stays open while you work, including when it hosts multiple backends.
From bc2441fed3bd1372f20f5ac6b8b94e3a35dae5d3 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:34:21 +0000
Subject: [PATCH 015/149] fix(server): clear canceled provider install queues
---
.../providerMaintenanceCommandCoordinator.ts | 3 +++
.../src/provider/providerMaintenanceRunner.test.ts | 3 +++
.../src/provider/providerMaintenanceRunner.ts | 14 ++++++++++++++
3 files changed, 20 insertions(+)
diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
index 3dec5a7981291..9d5c953a85c88 100644
--- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
+++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
@@ -26,6 +26,7 @@ export interface ProviderMaintenanceCommandCoordinatorShape {
readonly targetKey: string;
readonly lockKey: string;
readonly onQueued?: Effect.Effect;
+ readonly onInterrupted?: Effect.Effect;
readonly admit?: (effect: Effect.Effect) => Effect.Effect;
readonly run: Effect.Effect;
}) => Effect.Effect;
@@ -59,6 +60,7 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn(
targetKey,
lockKey,
onQueued,
+ onInterrupted,
admit,
run,
}) =>
@@ -70,6 +72,7 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn(
return yield* (onQueued ?? Effect.void).pipe(
Effect.andThen(admit ? admit(locks.withLock(lockKey, run)) : locks.withLock(lockKey, run)),
+ Effect.onInterrupt(() => onInterrupted ?? Effect.void),
Effect.ensuring(releaseTarget(targetKey)),
);
});
diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts
index a3c494e504b03..b1e936ece257c 100644
--- a/apps/server/src/provider/providerMaintenanceRunner.test.ts
+++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts
@@ -913,6 +913,9 @@ describe("providerMaintenanceRunner", () => {
yield* Fiber.interrupt(first);
releaseQueuedStateLatch.resolve();
+ const canceled = (yield* registry.getProviders)[0]?.updateState;
+ assert.strictEqual(canceled?.status, "failed");
+ assert.strictEqual(canceled?.message, "Update canceled before installation started.");
const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit);
assert.strictEqual(Exit.isSuccess(second), true);
diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts
index 109a554a6cb1e..90a8144fe5dff 100644
--- a/apps/server/src/provider/providerMaintenanceRunner.ts
+++ b/apps/server/src/provider/providerMaintenanceRunner.ts
@@ -586,6 +586,20 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
targetKey,
lockKey: update.lockKey,
onQueued: setQueuedState,
+ onInterrupted: Effect.gen(function* () {
+ const current = (yield* providerRegistry.getProviders).find(
+ (candidate) => candidate.instanceId === instanceId,
+ );
+ if (current?.updateState?.status !== "queued") return;
+ yield* setUpdateState(
+ makeUpdateState({
+ status: "failed",
+ startedAt: null,
+ finishedAt: yield* nowIso,
+ message: "Update canceled before installation started.",
+ }),
+ );
+ }),
// Admit before the installer lock: a manual update must not hold that
// lock while a background pass holding admission waits for it.
...(alreadyAdmitted ? {} : { admit: admission.withPermit }),
From 0b1a0a43fb9b2ce6a6dd37461fdc0ba8c7e4feb2 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:35:51 +0000
Subject: [PATCH 016/149] fix(server): keep replaced terminal shells busy
during updates
---
apps/server/src/terminal/Manager.test.ts | 29 ++++++++++++++++++++++--
apps/server/src/terminal/Manager.ts | 27 ++++++++++++++++++----
2 files changed, 49 insertions(+), 7 deletions(-)
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index 75d6e8a949e2f..e5483e9ad78e7 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -227,6 +227,7 @@ interface CreateManagerOptions {
readonly hasRunningSubprocess: boolean;
readonly childCommand: string | null;
readonly processIds: ReadonlyArray;
+ readonly terminalCommand?: string | null;
}>;
processTable?: Effect.Effect<
ReadonlyArray<{ readonly pid: number; readonly ppid: number; readonly name: string }>,
@@ -1279,6 +1280,7 @@ it.layer(
Effect.gen(function* () {
// FakePtyAdapter assigns pids from 9000 in open order.
const { manager, ptyAdapter } = yield* createManager(5, {
+ shellResolver: () => "/bin/zsh",
processTable: Effect.succeed([
{ pid: 9000, ppid: 1, name: "zsh" },
// An async prompt worker: a copy of the shell with no children.
@@ -1360,6 +1362,7 @@ it.layer(
];
let processes = shells;
const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
+ shellResolver: () => "/bin/zsh",
subprocessPollIntervalMs: 60_000,
processTable: Effect.sync(() => {
snapshotCalls += 1;
@@ -1383,6 +1386,15 @@ it.layer(
{ pid: 201, ppid: 200, name: "sleep" },
];
assert.equal(yield* manager.hasBusyTerminals, true);
+ // `exec` replaces the shell at its PID and leaves no child to inspect.
+ processes = shells.map((entry) =>
+ entry.pid === 9001 ? { ...entry, name: "/usr/bin/sleep" } : entry,
+ );
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ processes = shells.map((entry) => (entry.pid === 9001 ? { ...entry, name: "node" } : entry));
+ assert.equal(yield* manager.hasBusyTerminals, true);
+ processes = shells.map((entry) => (entry.pid === 9001 ? { ...entry, name: "" } : entry));
+ assert.equal(yield* manager.hasBusyTerminals, true);
processes = shells;
assert.equal(yield* manager.hasBusyTerminals, false);
expect(yield* getEvents).toEqual(events);
@@ -1410,6 +1422,7 @@ it.layer(
})),
};
const { manager, ptyAdapter } = yield* createManager(5, {
+ shellResolver: () => "/bin/zsh",
subprocessPollIntervalMs: 60_000,
}).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
@@ -1438,10 +1451,16 @@ it.layer(
Effect.gen(function* () {
let duringCheck: Effect.Effect = Effect.void;
const { manager, ptyAdapter } = yield* createManager(5, {
+ shellResolver: () => "/bin/zsh",
subprocessPollIntervalMs: 60_000,
subprocessInspector: () =>
duringCheck.pipe(
- Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
+ Effect.as({
+ hasRunningSubprocess: false,
+ childCommand: null,
+ processIds: [],
+ terminalCommand: "zsh",
+ }),
),
});
yield* manager.open(openInput());
@@ -1463,9 +1482,15 @@ it.layer(
it.effect("holds terminal launches and writes behind a server restart permit", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(5, {
+ shellResolver: () => "/bin/zsh",
subprocessPollIntervalMs: 60_000,
subprocessInspector: () =>
- Effect.succeed({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
+ Effect.succeed({
+ hasRunningSubprocess: false,
+ childCommand: null,
+ processIds: [],
+ terminalCommand: "zsh",
+ }),
});
yield* manager.open(openInput());
const held = yield* Deferred.make();
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index ee294307a243f..98f2202792ae9 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -253,8 +253,8 @@ interface TerminalSubprocessInspectResult {
readonly hasRunningSubprocess: boolean;
readonly childCommand: string | null;
readonly processIds: ReadonlyArray;
- /** False when an otherwise successful process snapshot omitted the shell. */
- readonly terminalExists?: boolean;
+ /** The normalized command currently running at the terminal's root PID. */
+ readonly terminalCommand?: string | null;
}
interface TerminalSubprocessInspector {
@@ -318,6 +318,8 @@ interface TerminalSessionState {
hasRunningSubprocess: boolean;
/** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */
childCommandLabel: string | null;
+ /** The shell originally spawned, so `exec` at its PID still counts as work. */
+ launchedShellCommand?: string | null;
runtimeEnv: Record | null;
}
@@ -730,6 +732,7 @@ function deriveSubprocessInspectResult(
const commandName = (pid: number) =>
normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform);
const shellName = commandName(terminalPid);
+ const terminalCommand = platform === "win32" ? (shellName?.toLowerCase() ?? null) : shellName;
// Async prompt themes fork the shell into a helper that waits with no
// children of its own. That copy is not a command the user started.
const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find(
@@ -743,7 +746,7 @@ function deriveSubprocessInspectResult(
hasRunningSubprocess: false,
childCommand: null,
processIds: [],
- terminalExists: snapshot.commandById.has(terminalPid),
+ terminalCommand,
};
}
const processIds = new Set([terminalPid]);
@@ -762,7 +765,7 @@ function deriveSubprocessInspectResult(
hasRunningSubprocess: true,
childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
processIds: [...processIds],
- terminalExists: snapshot.commandById.has(terminalPid),
+ terminalCommand,
};
}
@@ -2211,6 +2214,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
);
if (attempt._tag === "Success") {
+ const shellCommand = normalizeChildCommandName(
+ basenameForPlatform(candidate.shell, platform),
+ platform,
+ );
+ session.launchedShellCommand =
+ platform === "win32" ? (shellCommand?.toLowerCase() ?? null) : shellCommand;
return {
process: attempt.success,
shellLabel: formatShellCandidate(candidate),
@@ -3215,6 +3224,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
status: session.status,
pid: session.pid,
process: session.process,
+ launchedShellCommand: session.launchedShellCommand,
activityMark: session.eventSequence + session.inputCount,
},
] as const,
@@ -3226,7 +3236,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
for (const session of running.values()) {
if (session.pid === null || !Number.isInteger(session.pid) || session.pid <= 0) return true;
const result = yield* inspector(session.pid);
- if (result.hasRunningSubprocess || result.terminalExists === false) return true;
+ if (
+ result.hasRunningSubprocess ||
+ !result.terminalCommand ||
+ !session.launchedShellCommand ||
+ result.terminalCommand !== session.launchedShellCommand
+ ) {
+ return true;
+ }
}
// Input, output, or a new process can arrive after the shared snapshot.
const latest = yield* readManagerState;
From 0df12e8170ac317b49232aa8c5dfaf2553c07f3e Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:39:54 +0000
Subject: [PATCH 017/149] fix(server): mark launcher handoff as a terminal
effect
---
apps/server/src/updates/ServerAutoUpdater.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/src/updates/ServerAutoUpdater.ts b/apps/server/src/updates/ServerAutoUpdater.ts
index 946a489d4e5da..8c367e699d1a2 100644
--- a/apps/server/src/updates/ServerAutoUpdater.ts
+++ b/apps/server/src/updates/ServerAutoUpdater.ts
@@ -104,7 +104,7 @@ export const layer = Layer.effectDiscard(
yield* selfUpdate.update({ targetVersion });
// The launcher stops us after accepting the handoff. Keep new provider
// starts behind the install permit until this runtime is shut down.
- yield* Effect.never;
+ return yield* Effect.never;
}),
);
}).pipe(
From aac65cfc630499d469872c2dec7fabeb9a15152d Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:39:54 +0000
Subject: [PATCH 018/149] fix(server): preserve maintenance admission effect
types
---
.../src/provider/providerMaintenanceCommandCoordinator.ts | 4 ++--
apps/server/src/provider/providerMaintenanceRunner.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
index 9d5c953a85c88..49cdae4ff995f 100644
--- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
+++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
@@ -11,7 +11,7 @@ export class ProviderMaintenanceAdmission extends Context.Service<
{
readonly withPermit: (effect: Effect.Effect) => Effect.Effect;
}
->()("t3/provider/ProviderMaintenanceAdmission") {}
+>()("t3/provider/providerMaintenanceCommandCoordinator/ProviderMaintenanceAdmission") {}
// Keep one layer reference so the update window and turn startup share its permit.
export const admissionLayer = Layer.effect(
@@ -27,7 +27,7 @@ export interface ProviderMaintenanceCommandCoordinatorShape {
readonly lockKey: string;
readonly onQueued?: Effect.Effect;
readonly onInterrupted?: Effect.Effect;
- readonly admit?: (effect: Effect.Effect) => Effect.Effect;
+ readonly admit?: ProviderMaintenanceAdmission["Service"]["withPermit"];
readonly run: Effect.Effect;
}) => Effect.Effect;
}
diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts
index 90a8144fe5dff..d31aec3c3f00a 100644
--- a/apps/server/src/provider/providerMaintenanceRunner.ts
+++ b/apps/server/src/provider/providerMaintenanceRunner.ts
@@ -390,7 +390,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
const updateProvider = Effect.fn("ProviderMaintenanceRunner.updateProvider")(function* (
target: Parameters[0],
- alreadyAdmitted = false,
+ alreadyAdmitted: boolean = false,
) {
const provider = typeof target === "string" ? target : target.provider;
const instanceId =
From 526820e43770b3693d44f0b3d1e919a6c351da78 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:50:27 +0000
Subject: [PATCH 019/149] fix(dev): serve web assets and settle closed lab
sockets
---
scripts/update-lab.ts | 23 ++++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/scripts/update-lab.ts b/scripts/update-lab.ts
index 29f8d91327581..71dafb213ea10 100644
--- a/scripts/update-lab.ts
+++ b/scripts/update-lab.ts
@@ -54,7 +54,8 @@ t3 release feed, all under .t3/update-lab (state survives down/up).
build-runtime [--archive file]
Publish a t3 runtime to the release feed: a source runtime (copy of
apps/server stamped with , runs on this checkout's deps) or
- a real CLI archive built by the release pipeline.
+ a real CLI archive built by the release pipeline. Source runtimes
+ include apps/web/dist when present; otherwise they serve APIs only.
reset Delete the lab (only after down).
Providers: codex claude opencode pi grok. Install methods rotate across envs:
@@ -644,7 +645,8 @@ function compareVersions(a: string, b: string): number {
* Publish `t3--.tar.gz` + SHA256SUMS. Without `--archive`
* this is a source runtime: a copy of apps/server whose package.json carries
* `version` (every version check reads it) and whose node_modules links back
- * to this checkout, started by a `t3` wrapper.
+ * to this checkout, started by a `t3` wrapper. An existing web build is copied
+ * beside the server; publishing does not build the client.
*/
function buildRuntime(version: string, archive: string | undefined): string {
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
@@ -664,6 +666,12 @@ function buildRuntime(version: string, archive: string | undefined): string {
filter: (source) =>
!["node_modules", "dist", "dist-exe"].includes(NodePath.relative(serverSource, source)),
});
+ const webDist = NodePath.join(REPO, "apps/web/dist");
+ if (NodeFS.existsSync(NodePath.join(webDist, "index.html"))) {
+ NodeFS.cpSync(webDist, NodePath.join(stage, "web/dist"), { recursive: true });
+ } else {
+ console.warn("No apps/web/dist/index.html; publishing an API-only source runtime.");
+ }
const packageJsonPath = NodePath.join(stage, "server/package.json");
const packageJson = JSON.parse(NodeFS.readFileSync(packageJsonPath, "utf8"));
packageJson.version = version;
@@ -923,8 +931,14 @@ async function rpc(state: LabState, env: LabEnv, tag: string, payload: unknown):
`${origin.replace("http", "ws")}/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL}&wsTicket=${encodeURIComponent(ticket)}`,
);
return await new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error(`${tag} timed out`)), 10 * 60_000);
+ let settled = false;
+ const timer = setTimeout(
+ () => finish(() => reject(new Error(`${tag} timed out`))),
+ 10 * 60_000,
+ );
const finish = (settle: () => void) => {
+ if (settled) return;
+ settled = true;
clearTimeout(timer);
socket.close();
settle();
@@ -933,6 +947,9 @@ async function rpc(state: LabState, env: LabEnv, tag: string, payload: unknown):
socket.send(JSON.stringify({ _tag: "Request", id: "1", tag, payload, headers: [] })),
);
socket.addEventListener("error", () => finish(() => reject(new Error(`${tag}: socket error`))));
+ socket.addEventListener("close", () =>
+ finish(() => reject(new Error(`${tag}: socket closed before response`))),
+ );
socket.addEventListener("message", (event) => {
const decoded = JSON.parse(String(event.data));
for (const message of Array.isArray(decoded) ? decoded : [decoded]) {
From 22149e999e7e5d365cc41674a17d2c71bf5cc347 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 20:59:27 +0000
Subject: [PATCH 020/149] fix(dev): reject live update lab host changes
---
scripts/update-lab.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/scripts/update-lab.ts b/scripts/update-lab.ts
index 71dafb213ea10..13ab1e3bf2de7 100644
--- a/scripts/update-lab.ts
+++ b/scripts/update-lab.ts
@@ -1007,6 +1007,14 @@ async function up(args: string[]): Promise {
envs: [],
};
let state = updateState((next) => {
+ if (
+ next.host !== host &&
+ next.envs.some((env) => isLabProcess(env.pid, serverMarker(env), projectDir(env.id)))
+ ) {
+ fail(
+ "Cannot change --host while lab servers are running. Run `node scripts/update-lab.ts down` first.",
+ );
+ }
next.host = host;
next.busySeconds = Number(option(args, "busy-seconds") ?? next.busySeconds);
next.fromVersion = option(args, "from-version") ?? next.fromVersion;
From 65a7cb779b2eddb07ff0e9912a76ae44d5bd3881 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 21:03:16 +0000
Subject: [PATCH 021/149] fix(dev): reuse existing projects for lab turns
---
scripts/update-lab.ts | 36 +++++++++++++++++++++++-------------
1 file changed, 23 insertions(+), 13 deletions(-)
diff --git a/scripts/update-lab.ts b/scripts/update-lab.ts
index 13ab1e3bf2de7..7267d56422ccb 100644
--- a/scripts/update-lab.ts
+++ b/scripts/update-lab.ts
@@ -1129,19 +1129,29 @@ function status(): void {
async function busy(state: LabState, env: LabEnv): Promise {
const headers = await authHeaders(env);
if (!env.projectId) {
- const projectId = NodeCrypto.randomUUID();
- const created = await fetch(`${originOf(state, env)}/api/projects/mutate`, {
- method: "POST",
- headers,
- body: JSON.stringify({
- type: "project.create",
- commandId: NodeCrypto.randomUUID(),
- projectId,
- title: `${env.id} lab project`,
- workspaceRoot: projectDir(env.id),
- }),
- });
- if (!created.ok) fail(`Project creation failed: ${created.status} ${await created.text()}`);
+ const response = await fetch(`${originOf(state, env)}/api/projects`, { headers });
+ if (!response.ok) fail(`Project snapshot failed: ${response.status} ${await response.text()}`);
+ const snapshot = (await response.json()) as {
+ projects: Array<{ id: string; workspaceRoot: string; deletedAt: string | null }>;
+ };
+ const existing = snapshot.projects.find(
+ (project) => project.workspaceRoot === projectDir(env.id) && project.deletedAt === null,
+ );
+ const projectId = existing?.id ?? NodeCrypto.randomUUID();
+ if (!existing) {
+ const created = await fetch(`${originOf(state, env)}/api/projects/mutate`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ type: "project.create",
+ commandId: NodeCrypto.randomUUID(),
+ projectId,
+ title: `${env.id} lab project`,
+ workspaceRoot: projectDir(env.id),
+ }),
+ });
+ if (!created.ok) fail(`Project creation failed: ${created.status} ${await created.text()}`);
+ }
updateState((next) => {
requireEnv(next, env.id).projectId = projectId;
});
From c1a98f405e3d403d5de6a095e226c742b4dc1af6 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 21:11:05 +0000
Subject: [PATCH 022/149] fix(server): keep terminal input available during
update checks
---
apps/server/src/updates/UpdateWindow.ts | 15 +++++++++++----
docs/user/updating.md | 3 ---
2 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index 51ae4778a800c..ba63c4e72cc2a 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -228,11 +228,18 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
const runIfOpen: UpdateWindow["Service"]["runIfOpen"] = (options, install) => {
const attempt = check(options).pipe(
- Effect.flatMap((status) => (status.open ? Effect.asSome(install) : Effect.succeedNone)),
- );
- return admission.withPermit(
- options.restartsServer ? terminals.withRestartPermit(attempt) : attempt,
+ Effect.flatMap((status) => {
+ if (!status.open) return Effect.succeedNone;
+ if (!options.restartsServer) return Effect.asSome(install);
+ // Keep input available during the full check, then close the terminal race before restart.
+ return terminals.withRestartPermit(
+ terminals.hasBusyTerminals.pipe(
+ Effect.flatMap((busy) => (busy ? Effect.succeedNone : Effect.asSome(install))),
+ ),
+ );
+ }),
);
+ return admission.withPermit(attempt);
};
const runWhenOpen: UpdateWindow["Service"]["runWhenOpen"] = (options, install) =>
Effect.gen(function* () {
diff --git a/docs/user/updating.md b/docs/user/updating.md
index cb0de606f90c2..2c27589a717ff 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -85,9 +85,6 @@ Then it installs:
Background services also wait for integrated terminal commands to finish.
-Desktop updates download in the background and install when you quit the app.
-The app stays open while you work, including when it hosts multiple backends.
-
Servers started from a terminal or with `npx` are never replaced automatically.
A failed provider update is retried after 6 hours or when a newer version is
published. A failed or rolled-back T3 Code update is not retried until a newer
From 37abc15e670593acee77eb885dc9cb4333e61ddf Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 21:18:49 +0000
Subject: [PATCH 023/149] chore(updates): keep simulation tooling local
---
apps/server/src/updates/UpdateWindow.ts | 31 +-
package.json | 1 -
.../src/server/maintenanceResolver.ts | 2 +-
scripts/update-lab.ts | 1294 -----------------
4 files changed, 13 insertions(+), 1315 deletions(-)
delete mode 100644 scripts/update-lab.ts
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index 430baea17c4f1..4f9051ec7602a 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -1,5 +1,4 @@
import type { ScheduledTaskId, ThreadId } from "@t3tools/contracts";
-import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
@@ -17,24 +16,18 @@ import * as ServerSettings from "../serverSettings.ts";
/** Work due this soon keeps the window closed, so it does not start late. */
const UPDATE_LOOKAHEAD = Duration.minutes(5);
-/**
- * Background update pacing. `T3CODE_UPDATE_LAB=1` (scripts/update-lab.ts)
- * shortens the waits so update flows can be exercised in seconds.
- */
-export const updateTimings = Config.Boolean("T3CODE_UPDATE_LAB").pipe(
- Config.withDefault(false),
- Config.map((lab) => ({
- /**
- * How long every client must be untouched before an update may interrupt
- * it. Clients report activity about every 25s, so this must stay well above that.
- */
- quietPeriod: lab ? Duration.seconds(60) : Duration.minutes(15),
- /** How often a waiting update rechecks the window or looks for new versions. */
- recheck: lab ? Duration.seconds(5) : Duration.seconds(30),
- firstPassDelay: lab ? Duration.seconds(10) : Duration.minutes(2),
- passInterval: lab ? Duration.seconds(20) : Duration.minutes(10),
- })),
-);
+/** Background update pacing. */
+export const updateTimings = Effect.succeed({
+ /**
+ * How long every client must be untouched before an update may interrupt
+ * it. Clients report activity about every 25s, so this must stay well above that.
+ */
+ quietPeriod: Duration.minutes(15),
+ /** How often a waiting update rechecks the window or looks for new versions. */
+ recheck: Duration.seconds(30),
+ firstPassDelay: Duration.minutes(2),
+ passInterval: Duration.minutes(10),
+});
export type UpdateWindowBlocker =
| { readonly type: "active-threads"; readonly threadIds: ReadonlyArray }
diff --git a/package.json b/package.json
index 2b839155031ab..55869fc4b3911 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,6 @@
"start:desktop": "vp run --filter @t3tools/desktop start",
"start:marketing": "vp run --filter @t3tools/marketing preview",
"start:mock-update-server": "node scripts/mock-update-server.ts",
- "update-lab": "node scripts/update-lab.ts",
"screenshots:mobile": "node scripts/mobile-showcase.ts",
"icons:export": "node scripts/export-brand-icons.ts",
"icons:check": "node scripts/export-brand-icons.ts --check",
diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts
index 794385b3755a1..8fcee193960d7 100644
--- a/packages/provider-core/src/server/maintenanceResolver.ts
+++ b/packages/provider-core/src/server/maintenanceResolver.ts
@@ -742,7 +742,7 @@ export function createProviderVersionAdvisory(input: {
const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org";
-/** npm's own registry override, so mirrors (and local fake registries) answer "latest". */
+/** Respect npm's registry override when checking mirrors for newer provider versions. */
const NpmRegistryUrlConfig = Config.String("npm_config_registry").pipe(
Config.withDefault(DEFAULT_NPM_REGISTRY_URL),
Config.map((url) => url.trim().replace(/\/+$/, "") || DEFAULT_NPM_REGISTRY_URL),
diff --git a/scripts/update-lab.ts b/scripts/update-lab.ts
deleted file mode 100644
index 7267d56422ccb..0000000000000
--- a/scripts/update-lab.ts
+++ /dev/null
@@ -1,1294 +0,0 @@
-#!/usr/bin/env node
-// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off globalFetch:off globalConsole:off preferSchemaOverJson:off - Host-side dev lab that spawns isolated servers and fake CLIs with Node APIs directly.
-/**
- * Update lab: several isolated T3 servers ("machines") with fake provider
- * CLIs, a fake npm registry and a fake t3 release feed, for exercising
- * provider, server and launcher update flows end to end. State lives in the
- * gitignored `/.t3/update-lab`. Fake CLIs and package managers re-enter
- * this file as `__tool`, and the registry/feed server as `__feed`, so the lab
- * stays one file. Run `node scripts/update-lab.ts --help`.
- */
-import * as NodeChildProcess from "node:child_process";
-import * as NodeCrypto from "node:crypto";
-import * as NodeFS from "node:fs";
-import * as NodeHttp from "node:http";
-import * as NodeNet from "node:net";
-import * as NodePath from "node:path";
-import * as NodeReadline from "node:readline";
-
-const REPO = NodePath.resolve(import.meta.dirname, "..");
-const LAB = NodePath.join(REPO, ".t3", "update-lab");
-const STATE_PATH = NodePath.join(LAB, "state.json");
-const RELEASES = NodePath.join(LAB, "releases");
-const SERVER_BIN = NodePath.join(REPO, "apps/server/src/bin.ts");
-const MOCK_ACP_AGENT = NodePath.join(REPO, "apps/server/scripts/acp-mock-agent.ts");
-const NODE = process.execPath;
-const SELF = import.meta.filename;
-const REPO_VERSION: string = JSON.parse(
- NodeFS.readFileSync(NodePath.join(REPO, "apps/server/package.json"), "utf8"),
-).version;
-// oxlint-disable-next-line t3code/no-global-process-runtime -- Plain Node dev script; the lab only targets this host.
-const PLATFORM_KEY = `${process.platform}-${process.arch}`;
-const ORCHESTRATION_PROTOCOL = "2";
-
-const HELP = `Usage: node scripts/update-lab.ts
-
-Isolated T3 servers with fake provider CLIs, a fake npm registry and a fake
-t3 release feed, all under .t3/update-lab (state survives down/up).
-
- up [--envs N] [--host IP] [--launcher N] [--from-version V] [--busy-seconds S]
- Start the feed and N servers (default 3; host 127.0.0.1). The last N
- envs (default 1) run under the real service launcher on runtime V
- (default: repo version minus one patch) so self-update can be tried.
- Prints each env's origin and one-time pairing URL. Rerun to resume.
- down Stop only the processes the lab recorded.
- status Envs, ports, liveness, installed vs latest.
- pair Mint a fresh pairing URL for env (e.g. env1).
- publish Set the registry's latest for a provider.
- fail on|off Make that env's installer/updater exit 1.
- delay Installer/updater duration (default 3000).
- providers Print the provider list clients see for env.
- update [v] Run server.updateProvider like the UI does.
- busy Start a mock-acp turn that runs --busy-seconds.
- self-update Run server.updateServer (launcher envs only).
- build-runtime [--archive file]
- Publish a t3 runtime to the release feed: a source runtime (copy of
- apps/server stamped with , runs on this checkout's deps) or
- a real CLI archive built by the release pipeline. Source runtimes
- include apps/web/dist when present; otherwise they serve APIs only.
- reset Delete the lab (only after down).
-
-Providers: codex claude opencode pi grok. Install methods rotate across envs:
-npm prefix, pnpm, bun, yarn, volta, vite-plus, Homebrew formula and cask,
-native installers (claude update, codex standalone, opencode upgrade,
-grok update, pi update --self) and mise (manual-only). Each env also has a
-"mock-acp" provider (apps/server/scripts/acp-mock-agent.ts) for busy turns.
-Servers cache npm "latest" for an hour: down && up after publish.
-Not covered: cursor, antigravity, muse (no npm advisory or network-only).`;
-
-type ProviderKey = "codex" | "claude" | "opencode" | "pi" | "grok";
-type Method =
- | "npm"
- | "pnpm"
- | "bun"
- | "yarn"
- | "volta"
- | "vite-plus"
- | "homebrew"
- | "native"
- | "mise";
-
-const PROVIDERS: Record<
- ProviderKey,
- {
- readonly command: string;
- readonly pkg: string;
- readonly driver: string;
- readonly installed: string;
- readonly latest: string;
- readonly brew: { readonly kind: "formula" | "cask"; readonly name: string } | null;
- }
-> = {
- codex: {
- command: "codex",
- pkg: "@openai/codex",
- driver: "codex",
- installed: "0.158.0",
- latest: "0.160.0",
- brew: { kind: "formula", name: "codex" },
- },
- claude: {
- command: "claude",
- pkg: "@anthropic-ai/claude-code",
- driver: "claudeAgent",
- installed: "2.1.285",
- latest: "2.1.290",
- brew: { kind: "cask", name: "claude-code" },
- },
- opencode: {
- command: "opencode",
- pkg: "opencode-ai",
- driver: "opencode",
- installed: "1.14.19",
- latest: "1.14.25",
- brew: { kind: "formula", name: "opencode" },
- },
- pi: {
- command: "pi",
- pkg: "@earendil-works/pi-coding-agent",
- driver: "pi",
- installed: "0.80.6",
- latest: "0.81.0",
- brew: null,
- },
- grok: {
- command: "grok",
- pkg: "@xai-official/grok",
- driver: "grok",
- installed: "1.0.13",
- latest: "1.0.20",
- brew: null,
- },
-};
-const PROVIDER_KEYS = Object.keys(PROVIDERS) as ProviderKey[];
-
-// Env N uses row (N-1) % rows; three envs already reach every resolver branch.
-const INSTALL_MATRIX: ReadonlyArray> = [
- { codex: "npm", claude: "native", opencode: "homebrew", pi: "pnpm", grok: "native" },
- { codex: "native", claude: "homebrew", opencode: "bun", pi: "native", grok: "npm" },
- { codex: "volta", claude: "yarn", opencode: "native", pi: "vite-plus", grok: "bun" },
- { codex: "mise", claude: "pnpm", opencode: "npm", pi: "yarn", grok: "native" },
- { codex: "vite-plus", claude: "bun", opencode: "volta", pi: "npm", grok: "pnpm" },
-];
-
-// The package manager each fake tool stands in for.
-const TOOL_METHOD: Record = {
- npm: "npm",
- pnpm: "pnpm",
- bun: "bun",
- yarn: "yarn",
- volta: "volta",
- vp: "vite-plus",
- brew: "homebrew",
-};
-
-interface Install {
- method: Method;
- version: string;
- failing: boolean;
-}
-
-interface LabEnv {
- id: string;
- port: number;
- pid: number | null;
- launcher: boolean;
- projectId: string | null;
- installs: Record;
-}
-
-interface LabState {
- host: string;
- feedPort: number;
- feedPid: number | null;
- delayMs: number;
- busySeconds: number;
- fromVersion: string;
- latest: Record;
- envs: LabEnv[];
-}
-
-// ---------------------------------------------------------------------------
-// State
-
-function readState(): LabState | null {
- if (!NodeFS.existsSync(STATE_PATH)) return null;
- return JSON.parse(NodeFS.readFileSync(STATE_PATH, "utf8"));
-}
-
-function requireState(): LabState {
- const state = readState();
- if (!state) fail("No lab yet. Run `node scripts/update-lab.ts up` first.");
- return state;
-}
-
-/** Keep the descriptor open so the kernel releases the lock even if this process dies. */
-function acquireLock(name: "state" | "processes"): () => void {
- NodeFS.mkdirSync(NodePath.dirname(LAB), { recursive: true });
- // Keep lock files outside LAB so reset cannot unlink a lock another process holds.
- const fd = NodeFS.openSync(`${LAB}.${name}.lock`, "a");
- try {
- NodeChildProcess.execFileSync("flock", ["-x", "3"], {
- stdio: ["ignore", "ignore", "inherit", fd],
- });
- } catch (error) {
- NodeFS.closeSync(fd);
- throw error;
- }
- return () => NodeFS.closeSync(fd);
-}
-
-/** Atomic replacement keeps readers from observing a partially written state. */
-function writeState(state: LabState): void {
- const temp = `${STATE_PATH}.${process.pid}.tmp`;
- NodeFS.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`);
- NodeFS.renameSync(temp, STATE_PATH);
-}
-
-function updateState(mutate: (state: LabState) => void, initial?: LabState): LabState {
- const release = acquireLock("state");
- try {
- const state = readState() ?? initial ?? requireState();
- mutate(state);
- NodeFS.mkdirSync(LAB, { recursive: true });
- writeState(state);
- return state;
- } finally {
- release();
- }
-}
-
-function requireEnv(state: LabState, id: string | undefined): LabEnv {
- const env = state.envs.find((candidate) => candidate.id === id);
- if (!env) fail(`Unknown env ${id ?? "(none)"}. Envs: ${state.envs.map((e) => e.id).join(", ")}`);
- return env;
-}
-
-function requireProvider(name: string | undefined): ProviderKey {
- if (name && name in PROVIDERS) return name as ProviderKey;
- fail(`Unknown provider ${name ?? "(none)"}. Providers: ${PROVIDER_KEYS.join(", ")}`);
-}
-
-function fail(message: string): never {
- process.stderr.write(`${message}\n`);
- process.exit(1);
-}
-
-const envDir = (id: string) => NodePath.join(LAB, "envs", id);
-const homeDir = (id: string) => NodePath.join(envDir(id), "home");
-const t3Home = (id: string) => NodePath.join(envDir(id), "t3");
-const projectDir = (id: string) => NodePath.join(envDir(id), "project");
-const logPath = (id: string) => NodePath.join(envDir(id), "server.log");
-const originOf = (state: LabState, env: LabEnv) => `http://${state.host}:${env.port}`;
-
-// ---------------------------------------------------------------------------
-// Fake installs
-
-function writeExecutable(path: string, content: string): void {
- NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
- NodeFS.writeFileSync(path, content, { mode: 0o755 });
-}
-
-function link(target: string, path: string): void {
- NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
- NodeFS.rmSync(path, { force: true });
- NodeFS.symlinkSync(target, path);
-}
-
-/** A shell shim that re-enters this file as a fake tool; `$0` names a shared volta shim. */
-function toolShim(envId: string, tool: string | null): string {
- const name = tool === null ? `"$(basename "$0")"` : `'${tool}'`;
- return `#!/bin/sh\nexec '${NODE}' '${SELF}' __tool '${envId}' ${name} "$@"\n`;
-}
-
-/** Search order shared by the server's PATH and the fake login shell profile. */
-function envPath(envId: string): string {
- const home = homeDir(envId);
- return [
- ".local/bin",
- ".npm-global/bin",
- ".local/share/pnpm",
- ".bun/bin",
- ".yarn/bin",
- ".volta/bin",
- ".vite-plus/bin",
- "homebrew/bin",
- ".opencode/bin",
- ".grok/bin",
- ".pi/bin",
- ".local/share/mise/shims",
- ]
- .map((dir) => NodePath.join(home, dir))
- .concat([NodePath.join(envDir(envId), "tools"), "/usr/local/bin", "/usr/bin", "/bin"])
- .join(":");
-}
-
-/** Lay a provider out the way its installer would, so the server's resolver sees real paths. */
-function layOutInstall(envId: string, provider: ProviderKey, method: Method): void {
- const home = homeDir(envId);
- const { command, pkg, brew } = PROVIDERS[provider];
- const shim = toolShim(envId, command);
- const at = (...parts: string[]) => NodePath.join(home, ...parts);
- switch (method) {
- case "npm": {
- const real = at(".npm-global/lib/node_modules", pkg, "bin", `${command}.js`);
- writeExecutable(real, shim);
- link(real, at(".npm-global/bin", command));
- return;
- }
- case "yarn": {
- const real = at(".config/yarn/global/node_modules", pkg, "bin", `${command}.js`);
- writeExecutable(real, shim);
- link(real, at(".yarn/bin", command));
- return;
- }
- case "pnpm":
- return writeExecutable(at(".local/share/pnpm", command), shim);
- case "bun":
- return writeExecutable(at(".bun/bin", command), shim);
- case "vite-plus":
- return writeExecutable(at(".vite-plus/bin", command), shim);
- case "volta":
- writeExecutable(at(".volta/bin/volta-shim"), toolShim(envId, null));
- writeExecutable(at(".volta/bin/volta"), toolShim(envId, "volta"));
- NodeFS.mkdirSync(at(".volta/tools/image/packages", ...pkg.split("/")), { recursive: true });
- link(at(".volta/bin/volta-shim"), at(".volta/bin", command));
- return;
- case "homebrew": {
- if (!brew) fail(`${provider} has no Homebrew package.`);
- const keg =
- brew.kind === "formula"
- ? at("homebrew/Cellar", brew.name, PROVIDERS[provider].installed, "bin", command)
- : at("homebrew/Caskroom", brew.name, PROVIDERS[provider].installed, command);
- writeExecutable(keg, shim);
- link(keg, at("homebrew/bin", command));
- writeExecutable(at("homebrew/bin/brew"), toolShim(envId, "brew"));
- return;
- }
- case "mise": {
- const real = at(".local/share/mise/installs", command, "latest", "bin", command);
- writeExecutable(real, shim);
- link(real, at(".local/share/mise/shims", command));
- return;
- }
- case "native":
- switch (provider) {
- case "claude":
- return writeExecutable(at(".local/bin/claude"), shim);
- case "codex": {
- const real = at(".codex/packages/standalone/current/bin/codex");
- writeExecutable(real, shim);
- link(real, at(".local/bin/codex"));
- return;
- }
- case "opencode":
- return writeExecutable(at(".opencode/bin/opencode"), shim);
- case "grok":
- return writeExecutable(at(".grok/bin/grok"), shim);
- case "pi":
- return writeExecutable(at(".pi/bin/pi"), shim);
- }
- }
-}
-
-function seedEnvFiles(state: LabState, env: LabEnv): void {
- const home = homeDir(env.id);
- NodeFS.mkdirSync(home, { recursive: true });
- // The server reads PATH from a login shell first; these dotfiles keep it on the lab PATH.
- const profile = `export PATH='${envPath(env.id)}'\n`;
- for (const file of [".bash_profile", ".bashrc", ".profile", ".zprofile", ".zshrc"]) {
- NodeFS.writeFileSync(NodePath.join(home, file), profile);
- }
- const tools = NodePath.join(envDir(env.id), "tools");
- for (const tool of ["npm", "pnpm", "bun", "yarn", "vp"]) {
- writeExecutable(NodePath.join(tools, tool), toolShim(env.id, tool));
- }
- link(NODE, NodePath.join(tools, "node"));
- for (const provider of PROVIDER_KEYS) {
- layOutInstall(env.id, provider, env.installs[provider].method);
- }
-
- const project = projectDir(env.id);
- if (!NodeFS.existsSync(NodePath.join(project, ".git"))) {
- NodeFS.mkdirSync(project, { recursive: true });
- NodeFS.writeFileSync(NodePath.join(project, "README.md"), `# ${env.id} lab project\n`);
- const git = (...args: string[]) =>
- NodeChildProcess.execFileSync("git", ["-C", project, ...args], { stdio: "ignore" });
- git("init", "-q");
- git("add", ".");
- git("-c", "user.name=Update Lab", "-c", "user.email=lab@localhost", "commit", "-qm", "init");
- }
-
- const settingsPath = NodePath.join(t3Home(env.id), "userdata", "settings.json");
- NodeFS.mkdirSync(NodePath.dirname(settingsPath), { recursive: true });
- const settings = NodeFS.existsSync(settingsPath)
- ? JSON.parse(NodeFS.readFileSync(settingsPath, "utf8"))
- : {};
- settings.providerInstances = {
- ...settings.providerInstances,
- opencode: { driver: "opencode", enabled: true },
- pi: { driver: "pi", enabled: true },
- grok: { driver: "grok", enabled: true },
- "mock-acp": {
- driver: "acpRegistry",
- displayName: "Mock ACP",
- enabled: true,
- environment: [
- { name: "T3_ACP_PROMPT_DELAY_MS", value: String(state.busySeconds * 1000) },
- { name: "T3_ACP_PROMPT_RESPONSE_TEXT", value: `${env.id} finished its busy turn.` },
- ],
- config: { source: "local", commandPath: NODE, commandArgs: [MOCK_ACP_AGENT] },
- },
- };
- settings.defaultModelSelection ??= { instanceId: "mock-acp", model: "default" };
- NodeFS.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
-}
-
-// ---------------------------------------------------------------------------
-// Fake CLIs and package managers (`__tool ...args`)
-
-async function runTool(envId: string, tool: string, args: string[]): Promise {
- const state = requireState();
- const env = requireEnv(state, envId);
- const provider = PROVIDER_KEYS.find((key) => PROVIDERS[key].command === tool);
- if (provider) return runProviderCli(state, env, provider, args);
- const method = TOOL_METHOD[tool];
- if (!method) fail(`update-lab: unknown fake tool ${tool}`);
- if (args[0] === "--version" || args[0] === "-v") return void console.log("0.0.0-update-lab");
-
- if (tool === "brew") {
- const prefix = NodePath.join(homeDir(env.id), "homebrew");
- if (args[0] === "--prefix") return void console.log(prefix);
- const name = args.findLast((arg) => !arg.startsWith("-"));
- const owner = PROVIDER_KEYS.find((key) => PROVIDERS[key].brew?.name === name);
- if (!owner) fail(`Error: No available formula or cask with the name "${name}".`);
- if (args[0] === "info") {
- const latest = state.latest[owner];
- return void console.log(
- JSON.stringify(
- PROVIDERS[owner].brew!.kind === "formula"
- ? { formulae: [{ versions: { stable: latest } }], casks: [] }
- : { formulae: [], casks: [{ version: `${latest},lab` }] },
- ),
- );
- }
- if (args[0] === "upgrade") return install(env.id, owner, tool, undefined);
- fail(`update-lab brew: unsupported ${args.join(" ")}`);
- }
-
- // npm/pnpm/bun/yarn/volta/vp install commands name ``, `@latest` or `@x.y.z`.
- for (const arg of args) {
- for (const key of PROVIDER_KEYS) {
- const { pkg } = PROVIDERS[key];
- if (arg !== pkg && !arg.startsWith(`${pkg}@`)) continue;
- const requested = arg.slice(pkg.length + 1);
- if (method === "npm") {
- const prefix = args[args.indexOf("--prefix") + 1];
- if (
- env.installs[key].method !== "npm" ||
- prefix !== NodePath.join(homeDir(env.id), ".npm-global")
- ) {
- fail(`npm error ${pkg} is not installed under --prefix ${prefix ?? "(default)"}`);
- }
- }
- return install(
- env.id,
- key,
- tool,
- requested && requested !== "latest" ? requested : undefined,
- );
- }
- }
- fail(`update-lab ${tool}: unsupported ${args.join(" ")}`);
-}
-
-async function runProviderCli(
- state: LabState,
- env: LabEnv,
- provider: ProviderKey,
- args: string[],
-): Promise {
- const version = env.installs[provider].version;
- const [first, second] = args;
- if (first === "--version" || first === "-v") {
- const banner = {
- codex: `codex-cli ${version}`,
- claude: `${version} (Claude Code)`,
- opencode: version,
- pi: version,
- grok: `grok ${version}`,
- }[provider];
- return void console.log(banner);
- }
- const native =
- (provider === "claude" && first === "update") ||
- (provider === "codex" && first === "update") ||
- (provider === "opencode" && first === "upgrade") ||
- (provider === "grok" && first === "update") ||
- (provider === "pi" && first === "update" && second === "--self");
- if (native) return install(env.id, provider, PROVIDERS[provider].command, undefined);
- if (provider === "grok" && first === "models") return void console.log("You are logged in");
- if (provider === "codex" && first === "app-server") return codexAppServer(env, version);
- // Session probes (claude stream-json, opencode serve, pi --mode rpc) are out of
- // scope: a quiet failure keeps installed + version, so advisories still show.
- process.stderr.write(`update-lab fake ${provider}: ${args.join(" ")} is not simulated\n`);
- process.exit(1);
-}
-
-/** Just enough of `codex app-server` for the status probe to read the version. */
-async function codexAppServer(env: LabEnv, version: string): Promise {
- const codexHome = process.env.CODEX_HOME || NodePath.join(homeDir(env.id), ".codex");
- const results: Record = {
- initialize: {
- codexHome,
- platformFamily: "unix",
- platformOs: "linux",
- userAgent: `codex_cli_rs/${version}`,
- },
- "account/read": { account: null, requiresOpenaiAuth: false },
- "skills/list": { data: [] },
- "model/list": { data: [] },
- };
- const lines = NodeReadline.createInterface({ input: process.stdin });
- for await (const line of lines) {
- let message: { id?: unknown; method?: string };
- try {
- message = JSON.parse(line);
- } catch {
- continue;
- }
- if (message.id === undefined || message.method === undefined) continue;
- const result = results[message.method];
- process.stdout.write(
- `${JSON.stringify(
- result === undefined
- ? { id: message.id, error: { code: -32601, message: `${message.method} not simulated` } }
- : { id: message.id, result },
- )}\n`,
- );
- }
-}
-
-/** Shared install path: progress output for `delayMs`, injected failure, then the new version. */
-async function install(
- envId: string,
- provider: ProviderKey,
- tool: string,
- requested: string | undefined,
-): Promise {
- const state = requireState();
- const env = requireEnv(state, envId);
- const current = env.installs[provider];
- const method = TOOL_METHOD[tool];
- if (method && method !== current.method) {
- fail(`${tool}: ${PROVIDERS[provider].pkg} is not managed by ${tool} here (${current.method}).`);
- }
- const target = requested ?? state.latest[provider];
- const steps = 5;
- for (let step = 1; step <= steps; step++) {
- console.log(`${tool}: fetching ${PROVIDERS[provider].pkg}@${target} [${step}/${steps}]`);
- await new Promise((resolve) => setTimeout(resolve, state.delayMs / steps));
- }
- if (requireState().envs.find((e) => e.id === envId)!.installs[provider].failing) {
- process.stderr.write(`${tool}: injected failure installing ${PROVIDERS[provider].pkg}\n`);
- process.exit(1);
- }
- updateState((next) => {
- requireEnv(next, envId).installs[provider].version = target;
- });
- console.log(`${tool}: installed ${PROVIDERS[provider].pkg}@${target} (was ${current.version})`);
-}
-
-// ---------------------------------------------------------------------------
-// Fake npm registry + t3 release feed (`__feed `)
-
-function runFeed(port: number): void {
- const server = NodeHttp.createServer((request, response) => {
- const url = new URL(request.url ?? "/", "http://lab");
- const send = (status: number, body: unknown) => {
- response.writeHead(status, { "content-type": "application/json" });
- response.end(JSON.stringify(body));
- };
- const state = readState();
- if (!state) return send(503, { error: "no lab state" });
-
- // GET /npm//latest and /npm/; the server URI-encodes scoped names.
- const npm = /^\/npm\/(.+?)(\/latest)?$/.exec(url.pathname);
- if (npm) {
- const name = decodeURIComponent(npm[1]!);
- const key = PROVIDER_KEYS.find((candidate) => PROVIDERS[candidate].pkg === name);
- if (!key) return send(404, { error: "Not found" });
- const version = state.latest[key];
- return npm[2]
- ? send(200, { name, version })
- : send(200, {
- name,
- "dist-tags": { latest: version },
- versions: { [version]: { name, version } },
- });
- }
-
- // GET /releases/index.json?page=N: GitHub's list-releases shape, newest first.
- if (url.pathname === "/releases/index.json") {
- const page = Number(url.searchParams.get("page") ?? "1");
- return send(
- 200,
- page === 1 ? publishedRuntimes().map((v) => ({ tag_name: `v${v}`, draft: false })) : [],
- );
- }
-
- // GET /releases/v/: SHA256SUMS and archives.
- const asset = /^\/releases\/(v[^/]+)\/([^/]+)$/.exec(url.pathname);
- if (asset) {
- const file = NodePath.join(RELEASES, asset[1]!, asset[2]!);
- if (!NodeFS.existsSync(file)) return send(404, { error: "Not found" });
- response.writeHead(200, { "content-length": NodeFS.statSync(file).size });
- return void NodeFS.createReadStream(file).pipe(response);
- }
- send(404, { error: "Not found" });
- });
- server.listen(port, "127.0.0.1");
-}
-
-function publishedRuntimes(): string[] {
- if (!NodeFS.existsSync(RELEASES)) return [];
- return NodeFS.readdirSync(RELEASES)
- .filter((dir) => NodeFS.existsSync(NodePath.join(RELEASES, dir, "SHA256SUMS")))
- .map((dir) => dir.slice(1))
- .sort((a, b) => compareVersions(b, a));
-}
-
-function compareVersions(a: string, b: string): number {
- const parse = (v: string) => v.split(/[.-]/).map((part) => Number(part) || 0);
- const [left, right] = [parse(a), parse(b)];
- for (let i = 0; i < Math.max(left.length, right.length); i++) {
- const diff = (left[i] ?? 0) - (right[i] ?? 0);
- if (diff !== 0) return diff;
- }
- return 0;
-}
-
-// ---------------------------------------------------------------------------
-// Runtimes for the release feed and the service launcher
-
-/**
- * Publish `t3--.tar.gz` + SHA256SUMS. Without `--archive`
- * this is a source runtime: a copy of apps/server whose package.json carries
- * `version` (every version check reads it) and whose node_modules links back
- * to this checkout, started by a `t3` wrapper. An existing web build is copied
- * beside the server; publishing does not build the client.
- */
-function buildRuntime(version: string, archive: string | undefined): string {
- if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
- fail(`Not an exact version: ${version}`);
- const releaseDir = NodePath.join(RELEASES, `v${version}`);
- const fileName = `t3-${version}-${PLATFORM_KEY}.tar.gz`;
- const archivePath = NodePath.join(releaseDir, fileName);
- NodeFS.mkdirSync(releaseDir, { recursive: true });
- if (archive) {
- NodeFS.copyFileSync(NodePath.resolve(archive), archivePath);
- } else {
- const stage = NodePath.join(LAB, "build", `t3-${version}-${PLATFORM_KEY}`);
- NodeFS.rmSync(stage, { recursive: true, force: true });
- const serverSource = NodePath.join(REPO, "apps/server");
- NodeFS.cpSync(serverSource, NodePath.join(stage, "server"), {
- recursive: true,
- filter: (source) =>
- !["node_modules", "dist", "dist-exe"].includes(NodePath.relative(serverSource, source)),
- });
- const webDist = NodePath.join(REPO, "apps/web/dist");
- if (NodeFS.existsSync(NodePath.join(webDist, "index.html"))) {
- NodeFS.cpSync(webDist, NodePath.join(stage, "web/dist"), { recursive: true });
- } else {
- console.warn("No apps/web/dist/index.html; publishing an API-only source runtime.");
- }
- const packageJsonPath = NodePath.join(stage, "server/package.json");
- const packageJson = JSON.parse(NodeFS.readFileSync(packageJsonPath, "utf8"));
- packageJson.version = version;
- NodeFS.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
- NodeFS.symlinkSync(
- NodePath.join(serverSource, "node_modules"),
- NodePath.join(stage, "server/node_modules"),
- );
- writeExecutable(
- NodePath.join(stage, "t3"),
- `#!/bin/sh\nexec '${NODE}' "$(dirname "$0")/server/src/bin.ts" "$@"\n`,
- );
- NodeChildProcess.execFileSync("tar", [
- "-czf",
- archivePath,
- "-C",
- NodePath.dirname(stage),
- NodePath.basename(stage),
- ]);
- NodeFS.rmSync(stage, { recursive: true, force: true });
- }
- const digest = NodeCrypto.createHash("sha256")
- .update(NodeFS.readFileSync(archivePath))
- .digest("hex");
- NodeFS.writeFileSync(NodePath.join(releaseDir, "SHA256SUMS"), `${digest} ${fileName}\n`);
- return archivePath;
-}
-
-/** Install a published runtime the way pinnedRuntime does, and make it the launcher's active version. */
-function installLauncherRuntime(envId: string, version: string): void {
- const runtimeDir = NodePath.join(t3Home(envId), "runtime");
- const versionDir = NodePath.join(runtimeDir, "versions", version);
- if (!NodeFS.existsSync(NodePath.join(versionDir, ".install-complete"))) {
- const archive = NodePath.join(RELEASES, `v${version}`, `t3-${version}-${PLATFORM_KEY}.tar.gz`);
- if (!NodeFS.existsSync(archive)) buildRuntime(version, undefined);
- NodeFS.mkdirSync(versionDir, { recursive: true });
- NodeChildProcess.execFileSync("tar", [
- "-xzf",
- archive,
- "-C",
- versionDir,
- "--strip-components=1",
- ]);
- NodeFS.writeFileSync(NodePath.join(versionDir, ".install-complete"), `${version}\n`);
- }
- const statePath = NodePath.join(runtimeDir, "service-state.json");
- if (!NodeFS.existsSync(statePath)) {
- NodeFS.writeFileSync(statePath, `${JSON.stringify({ protocol: 3, activeVersion: version })}\n`);
- }
-}
-
-// ---------------------------------------------------------------------------
-// Processes
-
-function listeningPorts(): Set {
- const output = NodeChildProcess.execFileSync("ss", ["-H", "-ltn"], { encoding: "utf8" });
- return new Set(
- output
- .split("\n")
- .map((line) => Number(line.trim().split(/\s+/)[3]?.split(":").pop()))
- .filter((port) => Number.isInteger(port) && port > 0),
- );
-}
-
-/** An OS-assigned port that `ss` does not already list and nobody else in the lab holds. */
-async function freePort(host: string, taken: Set): Promise {
- for (let attempt = 0; attempt < 20; attempt++) {
- const port = await new Promise((resolve, reject) => {
- const probe = NodeNet.createServer();
- probe.once("error", reject);
- probe.listen(0, host, () => {
- const address = probe.address();
- probe.close(() => resolve(typeof address === "object" && address ? address.port : 0));
- });
- });
- if (port > 0 && !taken.has(port) && !listeningPorts().has(port)) {
- taken.add(port);
- return port;
- }
- }
- fail("Could not find a free port.");
-}
-
-/** Check the recorded process's command and working directory before signaling it. */
-function isLabProcess(pid: number | null, marker: string, cwd = REPO): pid is number {
- if (!pid) return false;
- try {
- const args = NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
- return (
- args.includes(marker) &&
- args.includes(marker === "__feed" ? SELF : SERVER_BIN) &&
- NodeFS.realpathSync(`/proc/${pid}/cwd`) === NodeFS.realpathSync(cwd)
- );
- } catch {
- return false;
- }
-}
-
-const serverMarker = (env: LabEnv) => (env.launcher ? "__service-launcher" : t3Home(env.id));
-
-/** The runtime the service launcher runs now, which moves after a self-update. */
-function launcherLabel(env: LabEnv): string {
- if (!env.launcher) return "";
- const statePath = NodePath.join(t3Home(env.id), "runtime", "service-state.json");
- const active = NodeFS.existsSync(statePath)
- ? JSON.parse(NodeFS.readFileSync(statePath, "utf8")).activeVersion
- : "?";
- return ` launcher@${active}`;
-}
-
-/** The minimal environment a lab server sees; nothing from this shell leaks in. */
-function serverEnvironment(state: LabState, env: LabEnv): NodeJS.ProcessEnv {
- const home = homeDir(env.id);
- const keep = ["LANG", "LC_ALL", "TZ", "USER", "LOGNAME", "TMPDIR"];
- return {
- ...Object.fromEntries(
- keep.flatMap((key) => (process.env[key] ? [[key, process.env[key]]] : [])),
- ),
- HOME: home,
- PATH: envPath(env.id),
- SHELL: "/bin/bash",
- XDG_CONFIG_HOME: NodePath.join(home, ".config"),
- XDG_DATA_HOME: NodePath.join(home, ".local/share"),
- XDG_CACHE_HOME: NodePath.join(home, ".cache"),
- XDG_STATE_HOME: NodePath.join(home, ".local/state"),
- npm_config_registry: `http://127.0.0.1:${state.feedPort}/npm`,
- T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${state.feedPort}/releases`,
- T3CODE_UPDATE_LAB: "1",
- T3CODE_NO_BROWSER: "1",
- };
-}
-
-function spawnDetached(
- command: string,
- args: string[],
- options: { env: NodeJS.ProcessEnv; cwd: string; log: string },
-): number {
- const fd = NodeFS.openSync(options.log, "a");
- const child = NodeChildProcess.spawn(command, args, {
- cwd: options.cwd,
- env: options.env,
- detached: true,
- stdio: ["ignore", fd, fd],
- });
- NodeFS.closeSync(fd);
- child.unref();
- if (!child.pid) fail(`Failed to start ${command} ${args.join(" ")}`);
- return child.pid;
-}
-
-function startServer(state: LabState, env: LabEnv): number {
- NodeFS.writeFileSync(logPath(env.id), "");
- const environment = serverEnvironment(state, env);
- if (env.launcher) {
- installLauncherRuntime(env.id, state.fromVersion);
- // The launcher runs `/t3 serve` and passes its environment through.
- return spawnDetached(NODE, [SERVER_BIN, "__service-launcher"], {
- env: {
- ...environment,
- T3CODE_HOME: t3Home(env.id),
- T3CODE_PORT: String(env.port),
- T3CODE_HOST: state.host,
- },
- cwd: projectDir(env.id),
- log: logPath(env.id),
- });
- }
- return spawnDetached(
- NODE,
- [
- SERVER_BIN,
- "serve",
- "--base-dir",
- t3Home(env.id),
- "--port",
- String(env.port),
- "--host",
- state.host,
- projectDir(env.id),
- ],
- { env: environment, cwd: projectDir(env.id), log: logPath(env.id) },
- );
-}
-
-async function waitForPairingUrl(env: LabEnv): Promise {
- const deadline = Date.now() + 120_000;
- while (Date.now() < deadline) {
- const log = NodeFS.readFileSync(logPath(env.id), "utf8");
- const url = /Pairing URL: (\S+)/.exec(log)?.[1];
- if (url) return url;
- if (!isLabProcess(env.pid, serverMarker(env), projectDir(env.id))) {
- fail(`${env.id} exited during startup; see ${logPath(env.id)}\n${log.slice(-2000)}`);
- }
- await new Promise((resolve) => setTimeout(resolve, 500));
- }
- fail(`${env.id} did not print a pairing URL within 120s; see ${logPath(env.id)}`);
-}
-
-/** SIGTERM the process group we created, then SIGKILL it if it lingers. */
-async function stopGroup(pid: number, marker: string, cwd = REPO): Promise {
- if (!isLabProcess(pid, marker, cwd)) return;
- process.kill(-pid, "SIGTERM");
- const deadline = Date.now() + 15_000;
- while (Date.now() < deadline && isLabProcess(pid, marker, cwd)) {
- await new Promise((resolve) => setTimeout(resolve, 250));
- }
- try {
- process.kill(-pid, "SIGKILL");
- } catch {
- // The group is already gone.
- }
-}
-
-// ---------------------------------------------------------------------------
-// Talking to a lab server like a client does
-
-async function sessionToken(env: LabEnv): Promise {
- const tokenPath = NodePath.join(envDir(env.id), "session-token");
- if (NodeFS.existsSync(tokenPath)) return NodeFS.readFileSync(tokenPath, "utf8").trim();
- const token = NodeChildProcess.execFileSync(
- NODE,
- [
- SERVER_BIN,
- "auth",
- "session",
- "issue",
- "--base-dir",
- t3Home(env.id),
- "--label",
- "update-lab",
- "--token-only",
- ],
- { cwd: REPO, encoding: "utf8", env: { PATH: process.env.PATH, HOME: homeDir(env.id) } },
- ).trim();
- NodeFS.writeFileSync(tokenPath, token, { mode: 0o600 });
- return token;
-}
-
-async function authHeaders(env: LabEnv): Promise> {
- return {
- Authorization: `Bearer ${await sessionToken(env)}`,
- "Content-Type": "application/json",
- "x-t3-orchestration-protocol": ORCHESTRATION_PROTOCOL,
- };
-}
-
-/** One unary call over the server's Effect RPC WebSocket protocol (JSON frames). */
-async function rpc(state: LabState, env: LabEnv, tag: string, payload: unknown): Promise {
- const origin = originOf(state, env);
- const ticketResponse = await fetch(`${origin}/api/auth/websocket-ticket`, {
- method: "POST",
- headers: await authHeaders(env),
- });
- if (!ticketResponse.ok) fail(`${env.id}: websocket ticket failed (${ticketResponse.status})`);
- const { ticket } = (await ticketResponse.json()) as { ticket: string };
- const socket = new WebSocket(
- `${origin.replace("http", "ws")}/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL}&wsTicket=${encodeURIComponent(ticket)}`,
- );
- return await new Promise((resolve, reject) => {
- let settled = false;
- const timer = setTimeout(
- () => finish(() => reject(new Error(`${tag} timed out`))),
- 10 * 60_000,
- );
- const finish = (settle: () => void) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- socket.close();
- settle();
- };
- socket.addEventListener("open", () =>
- socket.send(JSON.stringify({ _tag: "Request", id: "1", tag, payload, headers: [] })),
- );
- socket.addEventListener("error", () => finish(() => reject(new Error(`${tag}: socket error`))));
- socket.addEventListener("close", () =>
- finish(() => reject(new Error(`${tag}: socket closed before response`))),
- );
- socket.addEventListener("message", (event) => {
- const decoded = JSON.parse(String(event.data));
- for (const message of Array.isArray(decoded) ? decoded : [decoded]) {
- if (message._tag !== "Exit" || message.requestId !== "1") continue;
- const exit = message.exit;
- finish(() =>
- exit._tag === "Success"
- ? resolve(exit.value)
- : reject(new Error(`${tag} failed: ${JSON.stringify(exit.cause)}`)),
- );
- }
- });
- });
-}
-
-function printProviders(providers: ReadonlyArray): void {
- for (const provider of providers) {
- const advisory = provider.versionAdvisory ?? {};
- const update = provider.updateState ?? null;
- console.log(
- [
- ` ${provider.instanceId.padEnd(10)} ${String(provider.status).padEnd(8)}`,
- `installed=${provider.version ?? "-"} latest=${advisory.latestVersion ?? "-"} advisory=${advisory.status ?? "-"}`,
- `update=${advisory.canUpdate ? JSON.stringify(advisory.updateCommand) : "manual"}`,
- update ? `last=${update.status}: ${update.message ?? ""}` : "",
- ].join(" "),
- );
- }
-}
-
-// ---------------------------------------------------------------------------
-// Commands
-
-function option(args: string[], name: string): string | undefined {
- const index = args.indexOf(`--${name}`);
- return index >= 0 ? args[index + 1] : undefined;
-}
-
-async function up(args: string[]): Promise {
- const previous = readState();
- const host = option(args, "host") ?? previous?.host ?? "127.0.0.1";
- const envCount = Number(option(args, "envs") ?? previous?.envs.length ?? 3);
- const launcherCount = Number(option(args, "launcher") ?? 1);
- const [major, minor, patch] = REPO_VERSION.split(/[.-]/).map(Number);
- const initial: LabState = {
- host,
- feedPort: 0,
- feedPid: null,
- delayMs: 3000,
- busySeconds: 120,
- fromVersion: `${major}.${minor}.${Math.max(0, patch! - 1)}`,
- latest: Object.fromEntries(
- PROVIDER_KEYS.map((key) => [key, PROVIDERS[key].latest]),
- ) as LabState["latest"],
- envs: [],
- };
- let state = updateState((next) => {
- if (
- next.host !== host &&
- next.envs.some((env) => isLabProcess(env.pid, serverMarker(env), projectDir(env.id)))
- ) {
- fail(
- "Cannot change --host while lab servers are running. Run `node scripts/update-lab.ts down` first.",
- );
- }
- next.host = host;
- next.busySeconds = Number(option(args, "busy-seconds") ?? next.busySeconds);
- next.fromVersion = option(args, "from-version") ?? next.fromVersion;
- for (let index = next.envs.length; index < envCount; index++) {
- const row = INSTALL_MATRIX[index % INSTALL_MATRIX.length]!;
- next.envs.push({
- id: `env${index + 1}`,
- port: 0,
- pid: null,
- launcher: index >= envCount - launcherCount,
- projectId: null,
- installs: Object.fromEntries(
- PROVIDER_KEYS.map((key) => [
- key,
- { method: row[key], version: PROVIDERS[key].installed, failing: false },
- ]),
- ) as LabEnv["installs"],
- });
- }
- }, initial);
- const taken = new Set(state.envs.map((env) => env.port).concat(state.feedPort));
-
- if (!isLabProcess(state.feedPid, "__feed")) {
- const feedPort =
- state.feedPort > 0 && !listeningPorts().has(state.feedPort)
- ? state.feedPort
- : await freePort("127.0.0.1", taken);
- // Running servers retain the old feed origin in their environment until restarted.
- if (feedPort !== state.feedPort) {
- for (const env of state.envs) {
- if (env.pid) await stopGroup(env.pid, serverMarker(env), projectDir(env.id));
- state = updateState((next) => {
- requireEnv(next, env.id).pid = null;
- });
- }
- }
- const feedPid = spawnDetached(NODE, [SELF, "__feed", String(feedPort)], {
- env: { PATH: process.env.PATH },
- cwd: REPO,
- log: NodePath.join(LAB, "feed.log"),
- });
- state = updateState((next) => {
- next.feedPort = feedPort;
- next.feedPid = feedPid;
- });
- }
- // The feed always offers this checkout's version, the update a launcher env is behind on.
- if (!publishedRuntimes().includes(REPO_VERSION)) buildRuntime(REPO_VERSION, undefined);
-
- const started: LabEnv[] = [];
- for (const env of state.envs) {
- if (isLabProcess(env.pid, serverMarker(env), projectDir(env.id))) continue;
- seedEnvFiles(state, env);
- const port =
- !env.port || listeningPorts().has(env.port) ? await freePort(host, taken) : env.port;
- const pid = startServer(state, { ...env, port });
- state = updateState((next) => {
- const current = requireEnv(next, env.id);
- current.port = port;
- current.pid = pid;
- });
- started.push(requireEnv(state, env.id));
- }
- console.log(
- `feed http://127.0.0.1:${state.feedPort} (npm registry /npm, releases /releases)`,
- );
- const urls = await Promise.all(started.map(waitForPairingUrl));
- for (const env of state.envs) {
- const index = started.findIndex((candidate) => candidate.id === env.id);
- console.log(`${env.id.padEnd(9)} ${originOf(state, env)} pid ${env.pid}${launcherLabel(env)}`);
- console.log(` ${index >= 0 ? urls[index] : "(already running; `pair` mints a URL)"}`);
- }
-}
-
-async function down(): Promise {
- const state = readState();
- if (!state) return;
- for (const env of state.envs) {
- if (env.pid) await stopGroup(env.pid, serverMarker(env), projectDir(env.id));
- updateState((next) => {
- requireEnv(next, env.id).pid = null;
- });
- }
- if (state.feedPid) await stopGroup(state.feedPid, "__feed");
- updateState((next) => {
- next.feedPid = null;
- });
- console.log("Lab stopped.");
-}
-
-function status(): void {
- const state = requireState();
- const feed = isLabProcess(state.feedPid, "__feed") ? "up" : "down";
- console.log(
- `feed http://127.0.0.1:${state.feedPort} ${feed}; delay ${state.delayMs}ms; runtimes ${publishedRuntimes().join(", ") || "-"}`,
- );
- for (const env of state.envs) {
- const alive = isLabProcess(env.pid, serverMarker(env), projectDir(env.id));
- console.log(
- `${env.id} ${originOf(state, env)} pid ${env.pid ?? "-"} ${alive ? "up" : "down"}${launcherLabel(env)}`,
- );
- for (const key of PROVIDER_KEYS) {
- const install = env.installs[key];
- const behind = compareVersions(install.version, state.latest[key]) < 0 ? "behind" : "current";
- console.log(
- ` ${key.padEnd(9)} ${install.method.padEnd(9)} installed ${install.version.padEnd(8)} latest ${state.latest[key].padEnd(8)} ${behind}${install.failing ? " FAILING" : ""}`,
- );
- }
- }
-}
-
-async function busy(state: LabState, env: LabEnv): Promise {
- const headers = await authHeaders(env);
- if (!env.projectId) {
- const response = await fetch(`${originOf(state, env)}/api/projects`, { headers });
- if (!response.ok) fail(`Project snapshot failed: ${response.status} ${await response.text()}`);
- const snapshot = (await response.json()) as {
- projects: Array<{ id: string; workspaceRoot: string; deletedAt: string | null }>;
- };
- const existing = snapshot.projects.find(
- (project) => project.workspaceRoot === projectDir(env.id) && project.deletedAt === null,
- );
- const projectId = existing?.id ?? NodeCrypto.randomUUID();
- if (!existing) {
- const created = await fetch(`${originOf(state, env)}/api/projects/mutate`, {
- method: "POST",
- headers,
- body: JSON.stringify({
- type: "project.create",
- commandId: NodeCrypto.randomUUID(),
- projectId,
- title: `${env.id} lab project`,
- workspaceRoot: projectDir(env.id),
- }),
- });
- if (!created.ok) fail(`Project creation failed: ${created.status} ${await created.text()}`);
- }
- updateState((next) => {
- requireEnv(next, env.id).projectId = projectId;
- });
- env.projectId = projectId;
- }
- const threadId = NodeCrypto.randomUUID();
- await rpc(state, env, "orchestration.launchThread", {
- commandId: NodeCrypto.randomUUID(),
- threadId,
- projectId: env.projectId,
- title: `Busy for ${state.busySeconds}s`,
- modelSelection: { instanceId: "mock-acp", model: "default" },
- runtimeMode: "full-access",
- interactionMode: "default",
- workspaceStrategy: { type: "root" },
- initialMessage: { text: "Stay busy so updates have to wait.", attachments: [] },
- });
- console.log(`${env.id}: thread ${threadId} runs for ~${state.busySeconds}s on mock-acp.`);
-}
-
-async function main(argv: string[]): Promise {
- const [command, ...args] = argv;
- switch (command) {
- case "__tool":
- return runTool(args[0]!, args[1]!, args.slice(2));
- case "__feed":
- return runFeed(Number(args[0]));
- case "up":
- return up(args);
- case "down":
- return down();
- case "status":
- return status();
- case "pair": {
- const state = requireState();
- const env = requireEnv(state, args[0]);
- const output = NodeChildProcess.execFileSync(
- NODE,
- [
- SERVER_BIN,
- "auth",
- "pairing",
- "create",
- "--base-dir",
- t3Home(env.id),
- "--base-url",
- originOf(state, env),
- ],
- { cwd: REPO, encoding: "utf8", env: { PATH: process.env.PATH, HOME: homeDir(env.id) } },
- );
- return void console.log(/(http\S+\/pair#token=\S+)/.exec(output)?.[1] ?? output);
- }
- case "publish": {
- const provider = requireProvider(args[0]);
- if (!args[1]) fail("Usage: publish ");
- updateState((state) => {
- state.latest[provider] = args[1]!;
- });
- return void console.log(`${PROVIDERS[provider].pkg} latest is now ${args[1]}.`);
- }
- case "fail": {
- const provider = requireProvider(args[1]);
- if (args[2] !== "on" && args[2] !== "off") fail("Usage: fail on|off");
- updateState((state) => {
- requireEnv(state, args[0]).installs[provider].failing = args[2] === "on";
- });
- return void console.log(
- `${args[0]} ${provider} installs will ${args[2] === "on" ? "fail" : "succeed"}.`,
- );
- }
- case "delay": {
- const ms = Number(args[0]);
- if (!Number.isInteger(ms) || ms < 0) fail("Usage: delay ");
- updateState((state) => {
- state.delayMs = ms;
- });
- return void console.log(`Installers now take ${ms}ms.`);
- }
- case "providers": {
- const state = requireState();
- const env = requireEnv(state, args[0]);
- const result = await rpc(state, env, "server.getConfig", {});
- console.log(`${env.id} ${originOf(state, env)}`);
- return printProviders(result.providers);
- }
- case "update": {
- const state = requireState();
- const env = requireEnv(state, args[0]);
- const instanceId = args[1] ?? fail("Usage: update [version]");
- const driver =
- PROVIDER_KEYS.map((key) => PROVIDERS[key].driver).find((d) => d === instanceId) ??
- instanceId;
- const result = await rpc(state, env, "server.updateProvider", {
- provider: driver,
- instanceId,
- ...(args[2] ? { targetVersion: args[2] } : {}),
- });
- return printProviders(result.providers.filter((p: any) => p.instanceId === instanceId));
- }
- case "busy": {
- const state = requireState();
- return busy(state, requireEnv(state, args[0]));
- }
- case "self-update": {
- const state = requireState();
- const env = requireEnv(state, args[0]);
- if (!args[1]) fail("Usage: self-update ");
- const result = await rpc(state, env, "server.updateServer", { targetVersion: args[1] });
- return void console.log(JSON.stringify(result));
- }
- case "build-runtime": {
- if (!args[0]) fail("Usage: build-runtime [--archive file]");
- return void console.log(`Published ${buildRuntime(args[0], option(args, "archive"))}`);
- }
- case "reset": {
- const release = acquireLock("state");
- try {
- const state = readState();
- if (state && [state.feedPid, ...state.envs.map((e) => e.pid)].some((pid) => pid !== null)) {
- fail("Run `down` first.");
- }
- NodeFS.rmSync(LAB, { recursive: true, force: true });
- } finally {
- release();
- }
- return void console.log(`Removed ${LAB}.`);
- }
- default:
- console.log(HELP);
- if (command && command !== "--help" && command !== "help") process.exit(1);
- }
-}
-
-const argv = process.argv.slice(2);
-const release = ["up", "down", "reset"].includes(argv[0] ?? "") ? acquireLock("processes") : null;
-try {
- await main(argv);
-} finally {
- release?.();
-}
From 1f5eb23f03e7aee624ff3911f2b6824534f5cd2e Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 21:29:57 +0000
Subject: [PATCH 024/149] docs(updates): explain pending background work
---
docs/user/updating.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/user/updating.md b/docs/user/updating.md
index 2c27589a717ff..15f5d48b4c193 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -75,8 +75,9 @@ update can roll back to the previous version. If the update still fails:
## Automatic updates
**Settings → General → Update automatically** is on by default. Each environment
-waits until no thread is running or waiting for you, no client has been used for
-15 minutes, and no scheduled task or usage-limit resume is due within 5 minutes.
+waits until no thread is running or waiting for you, no background work is pending,
+no client has been used for 15 minutes, and no scheduled task or usage-limit resume
+is due within 5 minutes.
Then it installs:
- provider CLI updates that have a one-click update;
From f3963cc9079dc69634662cf7c4571b0b81e6dc13 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 21:38:56 +0000
Subject: [PATCH 025/149] docs(updates): clarify terminal wait during server
restarts
---
docs/user/updating.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/user/updating.md b/docs/user/updating.md
index 15f5d48b4c193..777fe42c9a0d5 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -84,7 +84,7 @@ Then it installs:
- the newest T3 Code release on its channel, when it runs as a background service
(`t3 service install`). It downloads first and restarts only inside that window.
-Background services also wait for integrated terminal commands to finish.
+T3 Code server restarts also wait for integrated terminal commands to finish.
Servers started from a terminal or with `npx` are never replaced automatically.
A failed provider update is retried after 6 hours or when a newer version is
From fc5a32193549438e86d098fdd6b7005c90e8c9d2 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 21:48:25 +0000
Subject: [PATCH 026/149] fix(desktop): prevent stale updates after channel
changes
---
.../src/updates/DesktopUpdates.test.ts | 104 +++++++++++++++++-
apps/desktop/src/updates/DesktopUpdates.ts | 41 +++++++
.../desktop/src/updates/updatesTestHarness.ts | 7 +-
docs/user/updating.md | 2 +
4 files changed, 152 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts
index ce5935fe2fb3e..3cf5361eecdfd 100644
--- a/apps/desktop/src/updates/DesktopUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.test.ts
@@ -614,6 +614,7 @@ describe("DesktopUpdates", () => {
it.effect("clears quitting state after an unexpected install setup failure", () => {
const harness = makeHarness({
+ platform: "win32",
stopBackend: Effect.die(new Error("backend stop failed")),
});
@@ -825,6 +826,95 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
+ it.effect("disarms a previous channel's download until a matching download completes", () => {
+ const harness = makeHarness({ platform: "win32" });
+
+ return Effect.scoped(
+ Effect.gen(function* () {
+ const updates = yield* DesktopUpdates.DesktopUpdates;
+ yield* updates.configure;
+ harness.emit("update-downloaded", { version: "1.2.4" });
+ yield* flushCallbacks;
+ assert.isTrue(harness.autoInstallOnAppQuit());
+
+ yield* updates.setChannel("nightly");
+ assert.isFalse(harness.autoInstallOnAppQuit());
+ const beforeStaleDownload = yield* updates.getState;
+ harness.emit("update-downloaded", { version: "1.2.4" });
+ // electron-updater decides whether to stage on macOS as the callback returns.
+ assert.isFalse(harness.autoInstallOnAppQuit());
+ yield* flushCallbacks;
+ assert.deepEqual(yield* updates.getState, beforeStaleDownload);
+ assert.isFalse((yield* updates.install).accepted);
+
+ harness.emit("update-downloaded", { version: "1.2.4-nightly.20260709.766" });
+ assert.isTrue(harness.autoInstallOnAppQuit());
+ yield* flushCallbacks;
+ assert.equal((yield* updates.getState).status, "downloaded");
+ assert.isTrue((yield* updates.install).accepted);
+ }),
+ ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
+ });
+
+ it.effect(
+ "keeps a staged macOS download's channel even when a newer release is available",
+ () => {
+ const harness = makeHarness();
+
+ return Effect.scoped(
+ Effect.gen(function* () {
+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
+ const updates = yield* DesktopUpdates.DesktopUpdates;
+ yield* updates.configure;
+ harness.emit("update-downloaded", { version: "1.2.4" });
+ yield* flushCallbacks;
+ harness.emit("update-available", { version: "1.2.5" });
+ yield* flushCallbacks;
+ const beforeChannelChange = yield* updates.getState;
+ assert.isNull(beforeChannelChange.downloadedVersion);
+
+ const exit = yield* updates.setChannel("nightly").pipe(Effect.exit);
+ assert.equal(exit._tag, "Failure");
+ if (exit._tag === "Failure") {
+ const error = Cause.squash(exit.cause);
+ assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelStagedError);
+ assert.include(error.message, "Install the downloaded update");
+ }
+ assert.deepEqual(yield* updates.getState, beforeChannelChange);
+ assert.equal((yield* settings.get).updateChannel, "latest");
+ assert.isTrue(harness.autoInstallOnAppQuit());
+ assert.equal(harness.checkCount(), 0);
+ assert.deepEqual(yield* updates.setChannel("latest"), beforeChannelChange);
+ assert.isTrue((yield* updates.check("manual")).checked);
+ }),
+ ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
+ },
+ );
+
+ it.effect("refuses a stale payload through both automatic and explicit install paths", () => {
+ const harness = makeHarness();
+
+ return Effect.scoped(
+ Effect.gen(function* () {
+ const updates = yield* DesktopUpdates.DesktopUpdates;
+ yield* updates.configure;
+ harness.emit("update-downloaded", { version: "1.2.4" });
+ yield* flushCallbacks;
+ const beforeStaleDownload = yield* updates.getState;
+
+ harness.emit("update-downloaded", { version: "1.2.5-nightly.20260710.1" });
+ assert.isFalse(harness.autoInstallOnAppQuit());
+ yield* flushCallbacks;
+ assert.deepEqual(yield* updates.getState, beforeStaleDownload);
+ assert.isFalse((yield* updates.install).accepted);
+ assert.isFalse((yield* updates.installPrepared("1.2.4")).accepted);
+ assert.equal(harness.quitAndInstalls(), 0);
+ const error = yield* updates.setChannel("nightly").pipe(Effect.flip);
+ assert.equal(error._tag, "DesktopUpdateChannelStagedError");
+ }),
+ ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
+ });
+
it.effect("does not persist an unchanged update channel as a user preference", () => {
const harness = makeHarness();
@@ -896,6 +986,12 @@ describe("DesktopUpdates", () => {
const channelFiber = yield* updates.setChannel("nightly").pipe(Effect.forkScoped);
yield* Deferred.await(channelChangeStarted);
+ harness.emit("update-downloaded", { version: "1.2.4" });
+ assert.isFalse(harness.autoInstallOnAppQuit());
+ yield* flushCallbacks;
+ assert.isNull((yield* updates.getState).downloadedVersion);
+ assert.isFalse((yield* updates.install).accepted);
+
const checkResult = yield* updates.check("manual");
assert.isFalse(checkResult.checked);
assert.equal(harness.checkCount(), 0);
@@ -917,13 +1013,17 @@ describe("DesktopUpdates", () => {
path: "/tmp/settings.json",
cause: diskFailure,
});
- const harness = makeHarness({ setUpdateChannelError: settingsFailure });
+ const harness = makeHarness({ platform: "win32", setUpdateChannelError: settingsFailure });
return Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;
+ harness.emit("update-downloaded", { version: "1.2.4" });
+ yield* flushCallbacks;
+ const beforeChannelChange = yield* updates.getState;
+
const error = yield* updates.setChannel("nightly").pipe(Effect.flip);
assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError);
@@ -932,6 +1032,8 @@ describe("DesktopUpdates", () => {
assert.strictEqual(error.cause.cause, diskFailure);
assert.equal(error.message, "Failed to persist the nightly desktop update channel.");
assert.notInclude(error.message, diskFailure.message);
+ assert.deepEqual(yield* updates.getState, beforeChannelChange);
+ assert.isTrue(harness.autoInstallOnAppQuit());
const checkResult = yield* updates.check("manual");
assert.isTrue(checkResult.checked);
diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts
index 251c66ca9b71c..6c40d27d726da 100644
--- a/apps/desktop/src/updates/DesktopUpdates.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.ts
@@ -102,6 +102,15 @@ export class DesktopUpdateChannelPersistenceError extends Schema.TaggedError()(
+ "DesktopUpdateChannelStagedError",
+ { requestedChannel: DesktopUpdateChannelSchema },
+) {
+ override get message(): string {
+ return "Install the downloaded update by quitting and reopening the app before changing channels on macOS.";
+ }
+}
+
export class DesktopUpdatePollerError extends Schema.TaggedError()(
"DesktopUpdatePollerError",
{
@@ -155,6 +164,7 @@ export type DesktopUpdateConfigureError = never;
export const DesktopUpdateSetChannelError = Schema.Union([
DesktopUpdateActionInProgressError,
DesktopUpdateChannelPersistenceError,
+ DesktopUpdateChannelStagedError,
]);
export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type;
@@ -291,6 +301,10 @@ export const make = Effect.gen(function* () {
const updaterConfiguredRef = yield* Ref.make(false);
const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
const lastCheckReasonRef = yield* Ref.make(null);
+ // Feed checks can clear the UI's downloadedVersion without clearing the
+ // updater's payload. Native macOS staging cannot be cancelled afterward.
+ const downloadedPayloadVersionRef = yield* Ref.make(null);
+ const nativeUpdateStagedRef = yield* Ref.make(false);
const updateStateRef = yield* Ref.make(
createInitialDesktopUpdateState(
environment.appVersion,
@@ -618,6 +632,8 @@ export const make = Effect.gen(function* () {
const activeAction = yield* Ref.get(activeUpdateActionRef);
const hasExpectedDownload =
state.downloadedVersion !== null &&
+ state.downloadedVersion === (yield* Ref.get(downloadedPayloadVersionRef)) &&
+ resolveDefaultDesktopUpdateChannel(state.downloadedVersion) === state.channel &&
(expectedVersion === undefined || state.downloadedVersion === expectedVersion);
if (
(yield* Ref.get(desktopState.quitting)) ||
@@ -904,10 +920,30 @@ export const make = Effect.gen(function* () {
const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* (
raw: unknown,
) {
+ // Keep this prelude synchronous: MacUpdater checks the flag immediately
+ // after emitting this event, before it hands the download to Squirrel.
+ yield* electronUpdater.setAutoInstallOnAppQuit(false);
+ yield* Ref.set(downloadedPayloadVersionRef, null);
yield* decodeUpdateInfo(raw).pipe(
Effect.flatMap(
Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {
const state = yield* Ref.get(updateStateRef);
+ yield* Ref.set(downloadedPayloadVersionRef, info.version);
+ const action = yield* activeUpdateAction;
+ if (
+ resolveDefaultDesktopUpdateChannel(info.version) !== state.channel ||
+ (Option.isSome(action) && action.value === "channel")
+ ) {
+ yield* logUpdaterInfo("ignoring download outside the selected update channel", {
+ version: info.version,
+ channel: state.channel,
+ });
+ return;
+ }
+ if (environment.platform === "darwin") {
+ yield* Ref.set(nativeUpdateStagedRef, true);
+ }
+ yield* electronUpdater.setAutoInstallOnAppQuit(true);
yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));
yield* logUpdaterInfo("update downloaded", { version: info.version });
}),
@@ -1021,6 +1057,10 @@ export const make = Effect.gen(function* () {
return state;
}
+ if (environment.platform === "darwin" && (yield* Ref.get(nativeUpdateStagedRef))) {
+ return yield* new DesktopUpdateChannelStagedError({ requestedChannel: nextChannel });
+ }
+
yield* desktopSettings
.setUpdateChannel(nextChannel)
.pipe(
@@ -1029,6 +1069,7 @@ export const make = Effect.gen(function* () {
),
);
+ yield* electronUpdater.setAutoInstallOnAppQuit(false);
const enabled = yield* shouldEnableAutoUpdates;
yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts
index 9d4556af42b47..d843552a065ec 100644
--- a/apps/desktop/src/updates/updatesTestHarness.ts
+++ b/apps/desktop/src/updates/updatesTestHarness.ts
@@ -45,6 +45,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
let downloadCount = 0;
let allowDowngrade = false;
let fullChangelog = false;
+ let autoInstallOnAppQuit = true;
const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
const listeners = new Map void>>();
const sentStates: DesktopUpdateState[] = [];
@@ -73,7 +74,10 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
feedUrls.push(options);
}),
setAutoDownload: () => Effect.void,
- setAutoInstallOnAppQuit: () => Effect.void,
+ setAutoInstallOnAppQuit: (value) =>
+ Effect.sync(() => {
+ autoInstallOnAppQuit = value;
+ }),
setChannel: () => Effect.void,
setAllowPrerelease: () => Effect.void,
allowDowngrade: Effect.sync(() => allowDowngrade),
@@ -262,6 +266,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
downloadCount: () => downloadCount,
feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls,
fullChangelog: () => fullChangelog,
+ autoInstallOnAppQuit: () => autoInstallOnAppQuit,
listenerCount: () =>
Array.from(listeners.values()).reduce(
(total, eventListeners) => total + eventListeners.size,
diff --git a/docs/user/updating.md b/docs/user/updating.md
index 59857b3e4782e..eb2cbe00f8378 100644
--- a/docs/user/updating.md
+++ b/docs/user/updating.md
@@ -89,6 +89,8 @@ T3 Code server restarts also wait for integrated terminal commands to finish.
Desktop updates download in the background when the app detects an available
release and install when you quit the app.
The app stays open while you work, including when it hosts multiple backends.
+On macOS, quit and reopen the app to install a downloaded update before switching
+update channels.
Servers started from a terminal or with `npx` are never replaced automatically.
A failed provider update is retried after 6 hours or when a newer version is
From ce07c8670e26f504e9555e6f6a5935894bb139cc Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:18:21 +0000
Subject: [PATCH 027/149] refactor(server): simplify background provider
updates
---
.../server/src/background/BackgroundPolicy.ts | 9 +-
apps/server/src/cli/update.ts | 8 +-
.../Adapters/ClaudeAdapterV2.test.ts | 8 -
.../Adapters/ClaudeAdapterV2.ts | 2 +-
.../orchestration-v2/ProjectionStore.test.ts | 7 +-
.../src/orchestration-v2/ProjectionStore.ts | 28 +-
.../ProviderTurnStartService.ts | 2 +-
.../providerMaintenanceCommandCoordinator.ts | 26 +-
.../src/provider/providerMaintenanceRunner.ts | 24 +-
apps/server/src/serverSettings.test.ts | 52 ++--
apps/server/src/serverSettings.ts | 10 +-
.../server/src/updates/ProviderAutoUpdater.ts | 46 +---
apps/server/src/updates/UpdateWindow.ts | 258 ++++++------------
.../src/server/maintenanceResolver.ts | 13 +-
packages/shared/src/cliRelease.ts | 11 +-
15 files changed, 147 insertions(+), 357 deletions(-)
diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts
index 207c2454feede..48701d0fcaf03 100644
--- a/apps/server/src/background/BackgroundPolicy.ts
+++ b/apps/server/src/background/BackgroundPolicy.ts
@@ -16,7 +16,6 @@ import * as DateTime from "effect/DateTime";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-import * as Option from "effect/Option";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Scope from "effect/Scope";
@@ -41,8 +40,8 @@ export class BackgroundPolicy extends Context.Service<
) => Effect.Effect;
readonly reportHostPowerState: (snapshot: HostPowerSnapshot) => Effect.Effect;
readonly snapshot: Effect.Effect;
- /** When any client last reported recent interaction, so updates can wait for quiet. */
- readonly lastClientInteractionAt: Effect.Effect>;
+ /** When any client last reported recent interaction (boot time until one does), so updates can wait for quiet. */
+ readonly lastClientInteractionAt: Effect.Effect;
readonly streamChanges: Stream.Stream;
readonly subscribe: Effect.Effect<
{
@@ -216,7 +215,7 @@ export const make = Effect.fn("background.policy.make")(function* () {
const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const leasesRef = yield* Ref.make(new Map());
- const lastClientInteractionAtRef = yield* Ref.make(Option.none());
+ const lastClientInteractionAtRef = yield* Ref.make(yield* DateTime.now);
const changes = yield* PubSub.sliding(1);
const publishMutex = yield* Semaphore.make(1);
@@ -271,7 +270,7 @@ export const make = Effect.fn("background.policy.make")(function* () {
};
yield* Ref.update(leasesRef, (leases) => upsertClientActivityLease(leases, lease, now));
if (input.recentlyInteracted) {
- yield* Ref.set(lastClientInteractionAtRef, Option.some(now));
+ yield* Ref.set(lastClientInteractionAtRef, now);
}
yield* publishSnapshotUnlocked;
}),
diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts
index fa167e3bdeeb7..576158e316bc2 100644
--- a/apps/server/src/cli/update.ts
+++ b/apps/server/src/cli/update.ts
@@ -64,13 +64,12 @@ const RELEASE_INDEX_MAX_PAGES = 10;
/** Asks GitHub for the newest published version on a channel, page by page. */
const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
channel: CliReleaseChannel,
- releaseBaseUrl: string | undefined,
) {
const httpClient = yield* HttpClient.HttpClient;
for (let page = 1; page <= RELEASE_INDEX_MAX_PAGES; page += 1) {
const body = yield* httpClient
.execute(
- HttpClientRequest.get(cliReleaseIndexPageUrl(page, releaseBaseUrl)).pipe(
+ HttpClientRequest.get(cliReleaseIndexPageUrl(page)).pipe(
HttpClientRequest.setHeader("Accept", "application/vnd.github+json"),
),
)
@@ -348,7 +347,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
const service = yield* BootService.BootService;
const currentVersion = packageJson.version;
- const releaseBaseUrl = environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined;
const channel = input.channel ?? cliReleaseChannelOf(currentVersion);
if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) {
return yield* new CliUpdateError({
@@ -359,7 +357,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
progress.status("Checking for updates...");
const targetVersion = yield* (
input.requestedVersion === undefined
- ? resolveNewestVersion(channel, releaseBaseUrl)
+ ? resolveNewestVersion(channel)
: Effect.succeed(input.requestedVersion)
).pipe(Effect.ensuring(Effect.sync(progress.finish)));
const targetChannel = cliReleaseChannelOf(targetVersion);
@@ -493,7 +491,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
httpClient,
platform,
arch,
- releaseBaseUrl,
+ releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined,
validate: (paths) =>
runner
.run({
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index 437de43fb14d5..9c4e336815f76 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -3198,14 +3198,6 @@ describe("ClaudeAdapterV2 background wake turns", () => {
cacheCreationTokens: 0,
});
}
- const settledThread = harness.events.findLast(
- (event) => event.type === "provider_thread.updated",
- );
- assert.equal(settledThread?.type, "provider_thread.updated");
- if (settledThread?.type === "provider_thread.updated") {
- assert.equal(settledThread.providerThread.status, "idle");
- assert.isEmpty(settledThread.providerThread.pendingBackgroundTasks ?? []);
- }
}).pipe(
Effect.scoped,
Effect.provide(
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 859d7a0fe82b7..c7b1fb0d58190 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -5216,7 +5216,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
input.context.input.runOrdinal,
lastRunOrdinal: input.context.input.runOrdinal,
pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster),
- status: input.status === "completed" && roster.size > 0 ? "active" : "idle",
+ status: input.status === "completed" ? "active" : "idle",
updatedAt: input.completedAt,
};
yield* rememberProviderThread(providerThread);
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index f51e2f3ecde06..6b771ecced700 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
@@ -337,7 +337,6 @@ it.effect("memory recovery selection ignores unfinished items from rolled-back r
const threadId = yield* addRolledBackRecoveryCandidate("memory-recovery-candidates");
assert.notInclude(yield* projectionStore.getRecoveryThreadIds("runtime"), threadId);
- assert.notInclude(yield* projectionStore.getRecoveryThreadIds("active-work"), threadId);
}).pipe(Effect.provide(ProjectionStore.layerMemory)),
);
@@ -347,7 +346,8 @@ it.effect("memory recovery selection includes unfinished items from missing runs
const threadId = yield* addOrphanedRecoveryCandidate("memory-recovery-candidates");
assert.include(yield* projectionStore.getRecoveryThreadIds("runtime"), threadId);
- assert.include(yield* projectionStore.getRecoveryThreadIds("active-work"), threadId);
+ // An orphaned item must not hold background updates back forever.
+ assert.notInclude(yield* projectionStore.getRecoveryThreadIds("active-work"), threadId);
}).pipe(Effect.provide(ProjectionStore.layerMemory)),
);
@@ -2420,9 +2420,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
assert.notInclude(recoveryThreadIds, rolledBackThreadId);
const activeThreadIds = yield* projectionStore.getRecoveryThreadIds("active-work");
assert.include(activeThreadIds, runningThreadId);
- assert.include(activeThreadIds, orphanedThreadId);
+ assert.notInclude(activeThreadIds, orphanedThreadId);
assert.notInclude(activeThreadIds, settledThreadId);
- assert.notInclude(activeThreadIds, rolledBackThreadId);
}),
);
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index 8f55f052f1062..d377738547a13 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -577,16 +577,8 @@ function needsRecovery(
["preparing", "starting", "running", "waiting"].includes(run.status) ||
(run.status === "queued" && run.queueHeld !== true),
) ||
- projection.runtimeRequests.some((request) => request.status === "pending") ||
projection.providerThreads.some(
- (thread) =>
- thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0,
- ) ||
- projection.turnItems.some(
- (item) =>
- ["command_execution", "dynamic_tool", "subagent"].includes(item.type) &&
- ["pending", "running", "waiting"].includes(item.status) &&
- !projection.runs.some((run) => run.id === item.runId && run.status === "rolled_back"),
+ (thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0,
)
);
case "runtime":
@@ -3628,22 +3620,10 @@ export const layer: Layer.Layer =
THEN json_extract(payload_json, '$.queueHeld') IS NOT 1
ELSE 1 END
UNION
- SELECT thread_id FROM orchestration_v2_projection_runtime_requests
- WHERE status = 'pending'
- UNION
SELECT thread_id FROM orchestration_v2_projection_provider_threads
- WHERE status = 'active'
- OR CASE WHEN json_valid(payload_json)
- THEN json_array_length(payload_json, '$.pendingBackgroundTasks') > 0
- ELSE 0 END
- UNION
- SELECT item.thread_id FROM orchestration_v2_projection_turn_items AS item
- WHERE NOT EXISTS (
- SELECT 1 FROM orchestration_v2_projection_runs AS run
- WHERE run.run_id = item.run_id AND run.status = 'rolled_back'
- )
- AND type IN ('command_execution', 'dynamic_tool', 'subagent')
- AND status IN ('pending', 'running', 'waiting')
+ WHERE CASE WHEN json_valid(payload_json)
+ THEN json_array_length(payload_json, '$.pendingBackgroundTasks') > 0
+ ELSE 0 END
`;
case "runtime":
return sql`
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
index ebd12b4776a63..eca11701dab10 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
@@ -1292,4 +1292,4 @@ export const layer: Layer.Layer<
),
});
}),
-).pipe(Layer.provide(ProviderMaintenanceCoordinator.admissionLayer));
+);
diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
index 49cdae4ff995f..28cac15d4b23b 100644
--- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
+++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts
@@ -1,25 +1,15 @@
import * as KeyedLock from "@t3tools/shared/KeyedLock";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as Semaphore from "effect/Semaphore";
-/** Shares background install admission with provider startup, before either touches a CLI. */
-export class ProviderMaintenanceAdmission extends Context.Service<
- ProviderMaintenanceAdmission,
- {
- readonly withPermit: (effect: Effect.Effect) => Effect.Effect;
- }
->()("t3/provider/providerMaintenanceCommandCoordinator/ProviderMaintenanceAdmission") {}
-
-// Keep one layer reference so the update window and turn startup share its permit.
-export const admissionLayer = Layer.effect(
- ProviderMaintenanceAdmission,
- Effect.map(Semaphore.make(1), (permit) =>
- ProviderMaintenanceAdmission.of({ withPermit: permit.withPermits(1) }),
- ),
-);
+/** One permit per process: background installs hold it, provider turn starts wait for it. */
+export class ProviderMaintenanceAdmission extends Context.Reference<{
+ readonly withPermit: (effect: Effect.Effect) => Effect.Effect;
+}>("t3/provider/providerMaintenanceCommandCoordinator/ProviderMaintenanceAdmission", {
+ defaultValue: () => ({ withPermit: Semaphore.makeUnsafe(1).withPermits(1) }),
+}) {}
export interface ProviderMaintenanceCommandCoordinatorShape {
readonly withCommandLock: (input: {
@@ -27,7 +17,6 @@ export interface ProviderMaintenanceCommandCoordinatorShape {
readonly lockKey: string;
readonly onQueued?: Effect.Effect;
readonly onInterrupted?: Effect.Effect;
- readonly admit?: ProviderMaintenanceAdmission["Service"]["withPermit"];
readonly run: Effect.Effect;
}) => Effect.Effect;
}
@@ -61,7 +50,6 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn(
lockKey,
onQueued,
onInterrupted,
- admit,
run,
}) =>
Effect.gen(function* () {
@@ -71,7 +59,7 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn(
}
return yield* (onQueued ?? Effect.void).pipe(
- Effect.andThen(admit ? admit(locks.withLock(lockKey, run)) : locks.withLock(lockKey, run)),
+ Effect.andThen(locks.withLock(lockKey, run)),
Effect.onInterrupt(() => onInterrupted ?? Effect.void),
Effect.ensuring(releaseTarget(targetKey)),
);
diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts
index b874c5d897e94..ce0dc5ff7bd2a 100644
--- a/apps/server/src/provider/providerMaintenanceRunner.ts
+++ b/apps/server/src/provider/providerMaintenanceRunner.ts
@@ -27,11 +27,7 @@ import * as NodeUtil from "node:util";
import * as ModelManifest from "./ModelManifest.ts";
import { resolveProviderCompatibility } from "./providerCompatibility.ts";
import * as ProviderRegistry from "./ProviderRegistry.ts";
-import {
- admissionLayer,
- makeProviderMaintenanceCommandCoordinator,
- ProviderMaintenanceAdmission,
-} from "./providerMaintenanceCommandCoordinator.ts";
+import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
import {
enrichProviderSnapshotWithVersionAdvisory,
makeTargetedProviderUpdateAction,
@@ -71,8 +67,6 @@ export interface ProviderMaintenanceRunnerShape {
readonly targetVersion?: string | undefined;
},
) => Effect.Effect;
- /** Only for callers already holding the background update admission permit. */
- readonly updateProviderWhileAdmitted: ProviderMaintenanceRunnerShape["updateProvider"];
}
export class ProviderMaintenanceRunner extends Context.Service<
@@ -298,7 +292,6 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
- const admission = yield* ProviderMaintenanceAdmission;
const runMaintenanceCommand = (
update: ProviderMaintenanceCommandAction,
onProgress: (line: string) => Effect.Effect,
@@ -388,10 +381,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
}),
);
- const updateProvider = Effect.fn("ProviderMaintenanceRunner.updateProvider")(function* (
- target: Parameters[0],
- alreadyAdmitted: boolean = false,
- ) {
+ const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn(
+ "ProviderMaintenanceRunner.updateProvider",
+ )(function* (target) {
const provider = typeof target === "string" ? target : target.provider;
const instanceId =
typeof target === "string"
@@ -603,9 +595,6 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
}),
);
}),
- // Admit before the installer lock: a manual update must not hold that
- // lock while a background pass holding admission waits for it.
- ...(alreadyAdmitted ? {} : { admit: admission.withPermit }),
run: runProviderUpdate(),
})
.pipe(
@@ -622,10 +611,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
return ProviderMaintenanceRunner.of({
updateProvider,
- updateProviderWhileAdmitted: (target) => updateProvider(target, true),
});
});
-export const layer = Layer.effect(ProviderMaintenanceRunner, make()).pipe(
- Layer.provide(admissionLayer),
-);
+export const layer = Layer.effect(ProviderMaintenanceRunner, make());
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index 469adc22c87fd..f9bc173b91849 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -1030,44 +1030,24 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(layerServerSettings())),
);
- it.effect("disables automatic recovery and updates without rewriting an invalid file", () =>
- Effect.forEach(
- [
- {
- raw: '{"automaticUpdates":false,"continueThreadsAfterServerUpdate":false,"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}',
- migratedProvider: {
- driver: ProviderDriverKind.make("codex"),
- config: { binaryPath: "/x" },
- },
- },
- {
- raw: '{"automaticUpdates":true,"continueThreadsAfterServerUpdate":true,"providers":42}',
- migratedProvider: undefined,
- },
- {
- raw: '{"automaticUpdates":false,"continueThreadsAfterServerUpdate":false',
- migratedProvider: undefined,
- },
- ],
- ({ raw, migratedProvider }) =>
- Effect.gen(function* () {
- const serverConfig = yield* ServerConfig.ServerConfig;
- const fileSystem = yield* FileSystem.FileSystem;
- const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
- yield* fileSystem.writeFileString(serverConfig.settingsPath, raw);
+ it.effect("migrates legacy providers from an invalid file without rewriting it", () =>
+ Effect.gen(function* () {
+ const serverConfig = yield* ServerConfig.ServerConfig;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
+ const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}';
+ yield* fileSystem.writeFileString(serverConfig.settingsPath, raw);
- const settings = yield* serverSettings.getSettings;
+ const settings = yield* serverSettings.getSettings;
- // Readable legacy provider settings still apply while the file stays for repair.
- assert.deepEqual(
- settings.providerInstances[ProviderInstanceId.make("codex")],
- migratedProvider,
- );
- assert.isFalse(settings.automaticUpdates);
- assert.isFalse(settings.continueThreadsAfterServerUpdate);
- assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw);
- }).pipe(Effect.provide(layerServerSettings())),
- ),
+ // The readable provider settings still apply, but the file stays for the user to repair.
+ assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], {
+ driver: ProviderDriverKind.make("codex"),
+ config: { binaryPath: "/x" },
+ });
+ assert.isFalse(settings.automaticUpdates);
+ assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw);
+ }).pipe(Effect.provide(layerServerSettings())),
);
it.effect("skips a disabled provider instance when picking the text generation fallback", () =>
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index d05ddeafbc739..61cc0f29a0b3d 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -786,14 +786,10 @@ const make = Effect.gen(function* () {
if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
const failure = decoded._tag === "Failure" ? decoded : legacySettings;
settingsFileTrusted = false;
- // Unreadable persisted choices cannot authorize automatic updates or recovery.
- settings = {
- ...DEFAULT_SERVER_SETTINGS,
- automaticUpdates: false,
- continueThreadsAfterServerUpdate: false,
- };
+ // An unreadable file cannot authorize unattended updates.
+ settings = { ...DEFAULT_SERVER_SETTINGS, automaticUpdates: false };
if (failure._tag === "Failure") {
- yield* Effect.logWarning("failed to parse settings.json, using safe defaults", {
+ yield* Effect.logWarning("failed to parse settings.json, using defaults", {
path: settingsPath,
issues: Cause.pretty(failure.cause),
cause: failure.cause,
diff --git a/apps/server/src/updates/ProviderAutoUpdater.ts b/apps/server/src/updates/ProviderAutoUpdater.ts
index c6350733b69cf..db06bbcd95f64 100644
--- a/apps/server/src/updates/ProviderAutoUpdater.ts
+++ b/apps/server/src/updates/ProviderAutoUpdater.ts
@@ -15,6 +15,8 @@ import * as UpdateWindow from "./UpdateWindow.ts";
/** A failed install is retried for the same version only after this long. */
const RETRY_AFTER = Duration.hours(6);
+const FIRST_PASS_DELAY = Duration.minutes(2);
+const PASS_INTERVAL = Duration.minutes(10);
function isAutoUpdatable(provider: ServerProvider): boolean {
const advisory = provider.versionAdvisory;
@@ -42,62 +44,40 @@ export const layer = Layer.effectDiscard(
const runner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
const updateWindow = yield* UpdateWindow.UpdateWindow;
const serverSettings = yield* ServerSettings.ServerSettingsService;
- const timings = yield* UpdateWindow.updateTimings;
// Keyed by instance and target version, so a newer release retries at once.
const attempts = new Map();
const pass = Effect.gen(function* () {
- const settings = yield* serverSettings.getSettings;
- if (!settings.automaticUpdates || !settings.enableProviderUpdateChecks) return;
- const nowMs = yield* Clock.currentTimeMillis;
- const candidates = (yield* providers.getProviders).filter((provider) => {
- if (!isAutoUpdatable(provider)) return false;
- const attemptedAt = attempts.get(
- `${provider.instanceId}@${provider.versionAdvisory?.latestVersion}`,
- );
- return attemptedAt === undefined || nowMs - attemptedAt >= Duration.toMillis(RETRY_AFTER);
- });
- for (const provider of candidates) {
+ for (const { instanceId } of yield* providers.getProviders) {
const install = Effect.gen(function* () {
- const currentSettings = yield* serverSettings.getSettings;
- if (!currentSettings.automaticUpdates || !currentSettings.enableProviderUpdateChecks)
- return;
+ const settings = yield* serverSettings.getSettings;
+ if (!settings.automaticUpdates || !settings.enableProviderUpdateChecks) return;
const current = (yield* providers.getProviders).find(
- (candidate) => candidate.instanceId === provider.instanceId,
+ (candidate) => candidate.instanceId === instanceId,
);
if (!current || !isAutoUpdatable(current)) return;
const targetVersion = current.versionAdvisory?.latestVersion;
- const attemptKey = `${current.instanceId}@${targetVersion}`;
+ const attemptKey = `${instanceId}@${targetVersion}`;
const nowMs = yield* Clock.currentTimeMillis;
const attemptedAt = attempts.get(attemptKey);
if (attemptedAt !== undefined && nowMs - attemptedAt < Duration.toMillis(RETRY_AFTER))
return;
attempts.set(attemptKey, nowMs);
yield* Effect.logInfo("Updating provider in the background", {
- instanceId: provider.instanceId,
+ instanceId,
fromVersion: current.version,
targetVersion,
});
yield* runner
- .updateProviderWhileAdmitted({
- provider: current.driver,
- instanceId: current.instanceId,
- })
+ .updateProvider({ provider: current.driver, instanceId })
.pipe(
Effect.catchCause((cause) =>
- Effect.logWarning("Background provider update failed", {
- instanceId: provider.instanceId,
- cause,
- }),
+ Effect.logWarning("Background provider update failed", { instanceId, cause }),
),
);
});
// Rechecked per install: the previous one may have run for minutes.
- const installed = yield* updateWindow.runIfOpen({ closesWindows: false }, install);
- if (Option.isNone(installed)) {
- yield* Effect.logDebug("Provider auto-update waiting for the update window");
- return;
- }
+ if (Option.isNone(yield* updateWindow.runIfOpen(install))) return;
}
}).pipe(
Effect.catchCause((cause) =>
@@ -107,8 +87,8 @@ export const layer = Layer.effectDiscard(
);
yield* forkParked(
- Effect.sleep(timings.firstPassDelay).pipe(
- Effect.andThen(pass.pipe(Effect.repeat(Schedule.spaced(timings.passInterval)))),
+ Effect.sleep(FIRST_PASS_DELAY).pipe(
+ Effect.andThen(pass.pipe(Effect.repeat(Schedule.spaced(PASS_INTERVAL)))),
),
);
}),
diff --git a/apps/server/src/updates/UpdateWindow.ts b/apps/server/src/updates/UpdateWindow.ts
index 4f9051ec7602a..5c88feb1e41b5 100644
--- a/apps/server/src/updates/UpdateWindow.ts
+++ b/apps/server/src/updates/UpdateWindow.ts
@@ -1,4 +1,3 @@
-import type { ScheduledTaskId, ThreadId } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
@@ -15,69 +14,26 @@ import * as ServerSettings from "../serverSettings.ts";
/** Work due this soon keeps the window closed, so it does not start late. */
const UPDATE_LOOKAHEAD = Duration.minutes(5);
-
-/** Background update pacing. */
-export const updateTimings = Effect.succeed({
- /**
- * How long every client must be untouched before an update may interrupt
- * it. Clients report activity about every 25s, so this must stay well above that.
- */
- quietPeriod: Duration.minutes(15),
- /** How often a waiting update rechecks the window or looks for new versions. */
- recheck: Duration.seconds(30),
- firstPassDelay: Duration.minutes(2),
- passInterval: Duration.minutes(10),
-});
-
-export type UpdateWindowBlocker =
- | { readonly type: "active-threads"; readonly threadIds: ReadonlyArray }
- | { readonly type: "recent-interaction"; readonly quietAt: DateTime.Utc }
- | { readonly type: "focused-window" }
- | { readonly type: "provider-update" }
- | {
- readonly type: "scheduled-task";
- readonly taskId: ScheduledTaskId;
- readonly title: string;
- /** Null while the task is running. */
- readonly runsAt: DateTime.Utc | null;
- }
- | {
- readonly type: "usage-limit-resume";
- readonly threadId: ThreadId;
- readonly resumesAt: DateTime.Utc;
- };
-
-export interface UpdateWindowOptions {
- /** The update closes the app windows (a desktop install), not just a reconnect. */
- readonly closesWindows: boolean;
-}
-
-export interface UpdateWindowStatus {
- readonly open: boolean;
- readonly blockers: ReadonlyArray;
-}
+/**
+ * How long every client must be untouched before an update may interrupt it.
+ * Clients report activity about every 25s, so this must stay well above that.
+ */
+const QUIET_PERIOD = Duration.minutes(15);
/**
- * Decides when a background update may restart the server, replace a provider
- * CLI, or relaunch the desktop app without cutting off someone's work.
+ * Decides when a background update may restart the server or replace a
+ * provider CLI without cutting off someone's work.
*/
export class UpdateWindow extends Context.Service<
UpdateWindow,
{
- readonly check: (options: UpdateWindowOptions) => Effect.Effect;
/**
- * Runs `install` if the window is open, holding a lock every background
- * install shares, so a server restart never cuts off a CLI install.
+ * Runs `install` if the window is open, holding the admission permit that
+ * provider turn starts wait for, so a CLI is never replaced as a turn starts.
*/
readonly runIfOpen: (
- options: UpdateWindowOptions,
install: Effect.Effect,
) => Effect.Effect, E, R>;
- /** Waits for the window, then runs `install` like `runIfOpen`. */
- readonly runWhenOpen: (
- options: UpdateWindowOptions,
- install: Effect.Effect,
- ) => Effect.Effect;
}
>()("t3/updates/UpdateWindow") {}
@@ -87,146 +43,100 @@ const make = Effect.fn("updates.UpdateWindow.make")(function* () {
const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const providers = yield* ProviderRegistry.ProviderRegistry;
- const timings = yield* updateTimings;
const admission = yield* ProviderMaintenanceCoordinator.ProviderMaintenanceAdmission;
- const check: UpdateWindow["Service"]["check"] = Effect.fn("updates.UpdateWindow.check")(
- function* (options) {
- const now = yield* DateTime.now;
- const horizon = DateTime.addDuration(now, UPDATE_LOOKAHEAD);
- const blockers: Array = [];
+ /** Returns what keeps the window closed; empty means open. */
+ const blockers = Effect.fn("updates.UpdateWindow.blockers")(function* () {
+ const now = yield* DateTime.now;
+ const horizon = DateTime.addDuration(now, UPDATE_LOOKAHEAD);
+ const found: Array = [];
- // A failed read keeps the window closed: guessing "idle" could cut off a turn.
- const activeThreadIds = yield* projections.getRecoveryThreadIds("active-work").pipe(
- Effect.tapError((cause) =>
- Effect.logWarning("Update window could not read threads", { cause }),
- ),
- Effect.option,
- );
- if (Option.isNone(activeThreadIds)) {
- return { open: false, blockers: [{ type: "active-threads", threadIds: [] }] };
- }
- if (activeThreadIds.value.length > 0) {
- blockers.push({ type: "active-threads", threadIds: activeThreadIds.value });
- }
-
- // Includes installs someone started by hand.
- const providerUpdating = (yield* providers.getProviders).some(
- (provider) =>
- provider.updateState?.status === "queued" || provider.updateState?.status === "running",
- );
- if (providerUpdating) {
- blockers.push({ type: "provider-update" });
- }
+ // A failed read keeps the window closed: guessing "idle" could cut off a turn.
+ const activeThreadIds = yield* projections.getRecoveryThreadIds("active-work").pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read threads", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(activeThreadIds)) return ["active-threads"];
+ if (activeThreadIds.value.length > 0) found.push("active-threads");
- const lastInteraction = yield* backgroundPolicy.lastClientInteractionAt;
- if (Option.isSome(lastInteraction)) {
- const quietAt = DateTime.addDuration(lastInteraction.value, timings.quietPeriod);
- if (DateTime.isGreaterThan(quietAt, now)) {
- blockers.push({ type: "recent-interaction", quietAt });
- }
- }
+ // Includes installs someone started by hand.
+ const providerUpdating = (yield* providers.getProviders).some(
+ (provider) =>
+ provider.updateState?.status === "queued" || provider.updateState?.status === "running",
+ );
+ if (providerUpdating) found.push("provider-update");
- if (options.closesWindows) {
- const policy = yield* backgroundPolicy.snapshot;
- const hostIdleSeconds = policy.hostPower.stale ? null : policy.hostPower.idleSeconds;
- const hostAway =
- hostIdleSeconds !== null && hostIdleSeconds >= Duration.toSeconds(timings.quietPeriod);
- const windowFocused = policy.leases.some(
- (lease) => lease.clientKind === "desktop-renderer" && lease.focused,
- );
- if (windowFocused && !hostAway) {
- blockers.push({ type: "focused-window" });
- }
- }
+ const lastInteraction = yield* backgroundPolicy.lastClientInteractionAt;
+ if (DateTime.isGreaterThan(DateTime.addDuration(lastInteraction, QUIET_PERIOD), now)) {
+ found.push("recent-interaction");
+ }
- const tasks = yield* scheduledTasks.list().pipe(
- Effect.tapError((cause) =>
- Effect.logWarning("Update window could not read scheduled tasks", { cause }),
- ),
- Effect.option,
- );
- if (Option.isNone(tasks)) {
- return { open: false, blockers };
+ const tasks = yield* scheduledTasks.list().pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read scheduled tasks", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(tasks)) return [...found, "scheduled-task"];
+ for (const task of tasks.value.tasks) {
+ if (task.lastRunStatus === "running") {
+ found.push("scheduled-task");
+ continue;
}
- for (const task of tasks.value.tasks) {
- if (task.lastRunStatus === "running") {
- blockers.push({
- type: "scheduled-task",
- taskId: task.id,
- title: task.title,
- runsAt: null,
- });
- continue;
- }
- if (!task.enabled || task.nextRunAt === null) continue;
- const runsAt = DateTime.make(task.nextRunAt);
- if (Option.isSome(runsAt) && !DateTime.isGreaterThan(runsAt.value, horizon)) {
- blockers.push({
- type: "scheduled-task",
- taskId: task.id,
- title: task.title,
- runsAt: runsAt.value,
- });
- }
+ if (!task.enabled || task.nextRunAt === null) continue;
+ const runsAt = DateTime.make(task.nextRunAt);
+ if (Option.isSome(runsAt) && !DateTime.isGreaterThan(runsAt.value, horizon)) {
+ found.push("scheduled-task");
}
+ }
- const settings = yield* serverSettings.getSettings.pipe(
+ const settings = yield* serverSettings.getSettings.pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Update window could not read settings", { cause }),
+ ),
+ Effect.option,
+ );
+ if (Option.isNone(settings)) return [...found, "settings"];
+ const limited = yield* projections
+ .getLimitRecoveryCandidates({
+ now: horizon,
+ autoResume: settings.value.autoResumeLimitedThreads,
+ snooze: false,
+ })
+ .pipe(
Effect.tapError((cause) =>
- Effect.logWarning("Update window could not read settings", { cause }),
+ Effect.logWarning("Update window could not read usage-limit resumes", { cause }),
),
Effect.option,
);
- if (Option.isNone(settings) || !settings.value.automaticUpdates) {
- return { open: false, blockers };
- }
- const limited = yield* projections
- .getLimitRecoveryCandidates({
- now: horizon,
- autoResume: settings.value.autoResumeLimitedThreads,
- snooze: false,
- })
- .pipe(
- Effect.tapError((cause) =>
- Effect.logWarning("Update window could not read usage-limit resumes", { cause }),
- ),
- Effect.option,
- );
- if (Option.isNone(limited)) return { open: false, blockers };
- for (const thread of limited.value) {
- if (thread.usageLimitResetAt === null || thread.usageLimitResetAt === undefined) continue;
- const resumesAt = DateTime.make(thread.usageLimitResetAt);
- if (Option.isSome(resumesAt) && !DateTime.isGreaterThan(resumesAt.value, horizon)) {
- blockers.push({
- type: "usage-limit-resume",
- threadId: thread.id,
- resumesAt: resumesAt.value,
- });
- }
+ if (Option.isNone(limited)) return [...found, "usage-limit-resume"];
+ for (const thread of limited.value) {
+ if (thread.usageLimitResetAt === null || thread.usageLimitResetAt === undefined) continue;
+ const resumesAt = DateTime.make(thread.usageLimitResetAt);
+ if (Option.isSome(resumesAt) && !DateTime.isGreaterThan(resumesAt.value, horizon)) {
+ found.push("usage-limit-resume");
}
+ }
- return { open: blockers.length === 0, blockers };
- },
- );
+ return found;
+ });
- const runIfOpen: UpdateWindow["Service"]["runIfOpen"] = (options, install) =>
+ const runIfOpen: UpdateWindow["Service"]["runIfOpen"] = (install) =>
admission.withPermit(
- check(options).pipe(
- Effect.flatMap((status) => (status.open ? Effect.asSome(install) : Effect.succeedNone)),
+ blockers().pipe(
+ Effect.flatMap((found) =>
+ found.length === 0
+ ? Effect.asSome(install)
+ : Effect.logDebug("Background update waiting for the update window", {
+ blockers: [...new Set(found)],
+ }).pipe(Effect.as(Option.none())),
+ ),
),
);
- const runWhenOpen: UpdateWindow["Service"]["runWhenOpen"] = (options, install) =>
- Effect.gen(function* () {
- while (true) {
- const result = yield* runIfOpen(options, install);
- if (Option.isSome(result)) return result.value;
- yield* Effect.sleep(timings.recheck);
- }
- });
- return UpdateWindow.of({ check, runIfOpen, runWhenOpen });
+ return UpdateWindow.of({ runIfOpen });
});
-export const layer = Layer.effect(UpdateWindow, make()).pipe(
- Layer.provide(ProviderMaintenanceCoordinator.admissionLayer),
-);
+export const layer = Layer.effect(UpdateWindow, make());
diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts
index 8fcee193960d7..fb985bd2011af 100644
--- a/packages/provider-core/src/server/maintenanceResolver.ts
+++ b/packages/provider-core/src/server/maintenanceResolver.ts
@@ -740,21 +740,10 @@ export function createProviderVersionAdvisory(input: {
};
}
-const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org";
-
-/** Respect npm's registry override when checking mirrors for newer provider versions. */
-const NpmRegistryUrlConfig = Config.String("npm_config_registry").pipe(
- Config.withDefault(DEFAULT_NPM_REGISTRY_URL),
- Config.map((url) => url.trim().replace(/\/+$/, "") || DEFAULT_NPM_REGISTRY_URL),
-);
-
const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) {
const client = yield* HttpClient.HttpClient;
- const registryUrl = yield* NpmRegistryUrlConfig.pipe(
- Effect.orElseSucceed(() => DEFAULT_NPM_REGISTRY_URL),
- );
const request = HttpClientRequest.get(
- `${registryUrl}/${encodeURIComponent(packageName)}/latest`,
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
).pipe(HttpClientRequest.setHeader("accept", "application/json"));
const response = yield* client.execute(request).pipe(
Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS),
diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts
index 69d0daddb4fc0..28f0d530bb297 100644
--- a/packages/shared/src/cliRelease.ts
+++ b/packages/shared/src/cliRelease.ts
@@ -96,16 +96,9 @@ export function cliReleaseChannelOf(version: string): CliReleaseChannel {
* One page of GitHub's list-releases endpoint, newest first. Callers walk pages
* until a channel match turns up; a busy nightly train can push the newest
* preview or stable release past any single page.
- *
- * A mirror set through `T3CODE_RELEASE_BASE_URL` serves the same JSON (an
- * array of `{ tag_name, draft? }`, newest first, empty past the last page) at
- * `/index.json?per_page=100&page=N`.
*/
-export function cliReleaseIndexPageUrl(page: number, baseUrl?: string): string {
- const mirror = baseUrl?.trim().replace(/\/+$/, "");
- return mirror
- ? `${mirror}/index.json?per_page=100&page=${page}`
- : `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`;
+export function cliReleaseIndexPageUrl(page: number): string {
+ return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`;
}
/**
From 8429186d492a48a268dfc875c84d47db7526e768 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:20:39 +0000
Subject: [PATCH 028/149] refactor(server): simplify background server updates
---
apps/server/src/cloud/selfUpdate.ts | 18 +-
apps/server/src/terminal/Manager.test.ts | 181 -------------------
apps/server/src/terminal/Manager.ts | 145 ++++-----------
apps/server/src/updates/ServerAutoUpdater.ts | 33 ++--
4 files changed, 51 insertions(+), 326 deletions(-)
diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts
index a6d8c70740cc2..0e73689b98d33 100644
--- a/apps/server/src/cloud/selfUpdate.ts
+++ b/apps/server/src/cloud/selfUpdate.ts
@@ -15,7 +15,6 @@ import * as Effect from "effect/Effect";
import * as HashSet from "effect/HashSet";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
-import * as Semaphore from "effect/Semaphore";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
@@ -62,7 +61,7 @@ export class ServerSelfUpdate extends Context.Service<
) => Effect.Effect;
/** How this server can update itself, or null when it cannot. */
readonly capability: ServerSelfUpdateCapability | null;
- /** Downloads and preflights a boot-service runtime without restarting. */
+ /** Downloads and preflights a runtime without restarting. Only for `capability === "boot-service"`. */
readonly stage: (
targetVersion: string,
) => Effect.Effect<
@@ -196,8 +195,6 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
);
const inFlight = yield* Ref.make(false);
- // A background stage and a manual update may target the same version.
- const stageLock = yield* Semaphore.make(1);
const capability: ServerSelfUpdateCapability | null =
serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;
@@ -210,11 +207,6 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
update can stage first and restart later. */
const stageRuntime = Effect.fn("cloud.server_self_update.stage")(function* (
targetVersion: string,
- ) {
- return yield* stageLock.withPermits(1)(stageRuntimeUnlocked(targetVersion));
- });
- const stageRuntimeUnlocked = Effect.fn("cloud.server_self_update.stageUnlocked")(function* (
- targetVersion: string,
) {
let orchestrationProtocol: number | undefined;
const paths = yield* ensurePinnedRuntimeInstalled({
@@ -371,11 +363,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
desktopAppUpdate.commit(requestId, onHandoffAccepted),
capability,
stage: (targetVersion) =>
- capability === "boot-service"
- ? stageRuntime(targetVersion).pipe(
- Effect.map(({ orchestrationProtocol }) => ({ orchestrationProtocol })),
- )
- : Effect.fail(failWith("Only background service installs can stage an update.")),
+ stageRuntime(targetVersion).pipe(
+ Effect.map(({ orchestrationProtocol }) => ({ orchestrationProtocol })),
+ ),
});
});
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index e5483e9ad78e7..5a40f7bbb9334 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -227,7 +227,6 @@ interface CreateManagerOptions {
readonly hasRunningSubprocess: boolean;
readonly childCommand: string | null;
readonly processIds: ReadonlyArray;
- readonly terminalCommand?: string | null;
}>;
processTable?: Effect.Effect<
ReadonlyArray<{ readonly pid: number; readonly ppid: number; readonly name: string }>,
@@ -1280,7 +1279,6 @@ it.layer(
Effect.gen(function* () {
// FakePtyAdapter assigns pids from 9000 in open order.
const { manager, ptyAdapter } = yield* createManager(5, {
- shellResolver: () => "/bin/zsh",
processTable: Effect.succeed([
{ pid: 9000, ppid: 1, name: "zsh" },
// An async prompt worker: a copy of the shell with no children.
@@ -1299,15 +1297,7 @@ it.layer(
yield* manager.open(openInput({ terminalId: "subshell" }));
yield* manager.open(openInput({ threadId: "thread-2" }));
- assert.equal(yield* manager.hasBusyTerminals, true);
- expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
- false,
- false,
- false,
- false,
- ]);
yield* manager.closeIdle({ threadId: "thread-1" });
- assert.equal(yield* manager.hasBusyTerminals, true);
expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
true,
@@ -1352,177 +1342,6 @@ it.layer(
}),
);
- it.effect("checks fresh work across threads without changing idle shells or metadata", () =>
- Effect.gen(function* () {
- let snapshotCalls = 0;
- const shells = [
- { pid: 9000, ppid: 1, name: "zsh" },
- { pid: 100, ppid: 9000, name: "zsh" },
- { pid: 9001, ppid: 1, name: "zsh" },
- ];
- let processes = shells;
- const { manager, ptyAdapter, getEvents } = yield* createManager(5, {
- shellResolver: () => "/bin/zsh",
- subprocessPollIntervalMs: 60_000,
- processTable: Effect.sync(() => {
- snapshotCalls += 1;
- return processes;
- }),
- }).pipe(Effect.provide(layerWithHostPlatform("linux")));
-
- assert.equal(yield* manager.hasBusyTerminals, false);
- assert.equal(snapshotCalls, 0);
- yield* manager.open(openInput());
- yield* manager.open(openInput({ threadId: "thread-2" }));
- const events = yield* getEvents;
- assert.equal(yield* manager.hasBusyTerminals, false);
- assert.equal(snapshotCalls, 1);
-
- processes = [...shells, { pid: 200, ppid: 9001, name: "node" }];
- assert.equal(yield* manager.hasBusyTerminals, true);
- processes = [
- ...shells,
- { pid: 200, ppid: 9001, name: "zsh" },
- { pid: 201, ppid: 200, name: "sleep" },
- ];
- assert.equal(yield* manager.hasBusyTerminals, true);
- // `exec` replaces the shell at its PID and leaves no child to inspect.
- processes = shells.map((entry) =>
- entry.pid === 9001 ? { ...entry, name: "/usr/bin/sleep" } : entry,
- );
- assert.equal(yield* manager.hasBusyTerminals, true);
- processes = shells.map((entry) => (entry.pid === 9001 ? { ...entry, name: "node" } : entry));
- assert.equal(yield* manager.hasBusyTerminals, true);
- processes = shells.map((entry) => (entry.pid === 9001 ? { ...entry, name: "" } : entry));
- assert.equal(yield* manager.hasBusyTerminals, true);
- processes = shells;
- assert.equal(yield* manager.hasBusyTerminals, false);
- expect(yield* getEvents).toEqual(events);
- expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]);
- }),
- );
-
- it.effect("treats failed, truncated, timed out, and missing-shell inspections as busy", () =>
- Effect.gen(function* () {
- let stdout = "9000 1 zsh";
- let code = 0;
- let timedOut = false;
- let stdoutTruncated = false;
- const processRunner: ProcessRunner.ProcessRunner["Service"] = {
- run: () =>
- Effect.sync(() => ({
- stdout,
- stderr: "",
- code: ChildProcessSpawner.ExitCode(code),
- timedOut,
- stdoutTruncated,
- stderrTruncated: false,
- stdoutInvalidUtf8: false,
- stderrInvalidUtf8: false,
- })),
- };
- const { manager, ptyAdapter } = yield* createManager(5, {
- shellResolver: () => "/bin/zsh",
- subprocessPollIntervalMs: 60_000,
- }).pipe(
- Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
- Effect.provide(layerWithHostPlatform("linux")),
- );
- yield* manager.open(openInput());
- assert.equal(yield* manager.hasBusyTerminals, false);
- code = 1;
- assert.equal(yield* manager.hasBusyTerminals, true);
- code = 0;
- stdoutTruncated = true;
- assert.equal(yield* manager.hasBusyTerminals, true);
- stdoutTruncated = false;
- timedOut = true;
- assert.equal(yield* manager.hasBusyTerminals, true);
- timedOut = false;
- stdout = "";
- assert.equal(yield* manager.hasBusyTerminals, true);
- stdout = "9000 1 zsh";
- assert.equal(yield* manager.hasBusyTerminals, false);
- expect(ptyAdapter.processes[0]!.killed).toBe(false);
- }),
- );
-
- it.effect("treats input, output, and new terminals during inspection as busy", () =>
- Effect.gen(function* () {
- let duringCheck: Effect.Effect = Effect.void;
- const { manager, ptyAdapter } = yield* createManager(5, {
- shellResolver: () => "/bin/zsh",
- subprocessPollIntervalMs: 60_000,
- subprocessInspector: () =>
- duringCheck.pipe(
- Effect.as({
- hasRunningSubprocess: false,
- childCommand: null,
- processIds: [],
- terminalCommand: "zsh",
- }),
- ),
- });
- yield* manager.open(openInput());
- assert.equal(yield* manager.hasBusyTerminals, false);
- duringCheck = manager
- .write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "make build\r" })
- .pipe(Effect.orDie);
- assert.equal(yield* manager.hasBusyTerminals, true);
- duringCheck = Effect.sync(() => ptyAdapter.processes[0]!.emitData("make build\r\n"));
- assert.equal(yield* manager.hasBusyTerminals, true);
- duringCheck = manager
- .open(openInput({ threadId: "thread-2" }))
- .pipe(Effect.asVoid, Effect.orDie);
- assert.equal(yield* manager.hasBusyTerminals, true);
- expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]);
- }),
- );
-
- it.effect("holds terminal launches and writes behind a server restart permit", () =>
- Effect.gen(function* () {
- const { manager, ptyAdapter } = yield* createManager(5, {
- shellResolver: () => "/bin/zsh",
- subprocessPollIntervalMs: 60_000,
- subprocessInspector: () =>
- Effect.succeed({
- hasRunningSubprocess: false,
- childCommand: null,
- processIds: [],
- terminalCommand: "zsh",
- }),
- });
- yield* manager.open(openInput());
- const held = yield* Deferred.make();
- const release = yield* Deferred.make();
- const restartWindow = yield* manager
- .withRestartPermit(
- Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(release))),
- )
- .pipe(Effect.forkScoped);
- yield* Deferred.await(held);
- assert.equal(yield* manager.hasBusyTerminals, false);
-
- const write = yield* manager
- .write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "make build\r" })
- .pipe(Effect.forkScoped);
- const open = yield* manager.open(openInput({ threadId: "thread-2" })).pipe(Effect.forkScoped);
- const restart = yield* manager.restart(restartInput()).pipe(Effect.forkScoped);
- yield* Effect.yieldNow;
- expect(ptyAdapter.spawnInputs).toHaveLength(1);
- expect(ptyAdapter.processes[0]!.writes).toEqual([]);
- expect(ptyAdapter.processes[0]!.killed).toBe(false);
-
- yield* Deferred.succeed(release, undefined);
- yield* Fiber.join(restartWindow);
- yield* Fiber.join(write);
- yield* Fiber.join(open);
- yield* Fiber.join(restart);
- expect(ptyAdapter.spawnInputs).toHaveLength(3);
- expect(ptyAdapter.processes.flatMap((process) => process.writes)).toEqual(["make build\r"]);
- }),
- );
-
it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>
Effect.gen(function* () {
const fallbackCalls: Array = [];
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index bc6137878deae..82b820ec2e645 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -60,7 +60,6 @@ import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
-import * as Semaphore from "effect/Semaphore";
import * as SynchronizedRef from "effect/SynchronizedRef";
import * as ServerConfig from "../config.ts";
@@ -220,15 +219,9 @@ export class TerminalManager extends Context.Service<
readonly terminalId?: string;
}) => Effect.Effect;
- /** Inspect current terminal work without changing sessions. Unknown activity is busy. */
+ /** True while a terminal starts or the last subprocess poll saw a command running in one. */
readonly hasBusyTerminals: Effect.Effect