From f9353bdfea211d5ad2f18af7f552c13d205f0e17 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:27:28 +0000 Subject: [PATCH] fork: document the anchor machine as load-balancing weights; old setting still loads Where new threads of a shared project start is upstream load balancing: Prefer on one machine, Manual only on the rest. docs/fork/anchor-machine.md gives the steps and a "what happens when" table, including the cases where Send still goes to a machine you did not choose (a file attached before the move). It says plainly that threads without a project ignore the weights until upstream fixes its issue 31048; the upstream fix (PR 31049) is parked in a separate fork PR because it conflicts with upstream PR 35658. The earlier draft of this change saved a client setting anchorEnvironmentId. Two fork-only tests pin that settings holding it still load in the browser and the desktop app, keep the other choices, and drop the key on the next save. Co-Authored-By: Claude Opus 5.5 --- .../DesktopClientSettings.removedKeys.test.ts | 93 +++++++++++++++++++ ...ientPersistenceStorage.removedKeys.test.ts | 61 ++++++++++++ docs/fork/anchor-machine.md | 55 +++++++++++ 3 files changed, 209 insertions(+) create mode 100644 apps/desktop/src/settings/DesktopClientSettings.removedKeys.test.ts create mode 100644 apps/web/src/clientPersistenceStorage.removedKeys.test.ts create mode 100644 docs/fork/anchor-machine.md diff --git a/apps/desktop/src/settings/DesktopClientSettings.removedKeys.test.ts b/apps/desktop/src/settings/DesktopClientSettings.removedKeys.test.ts new file mode 100644 index 0000000000000..e1bf0fb5143aa --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.removedKeys.test.ts @@ -0,0 +1,93 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; + +// Fork-only. An earlier fork build saved `anchorEnvironmentId` in the client settings file. +// The setting is gone; a file that still holds it must load (an invalid file fails the read), +// keep the user's other choices, and lose the key on the next save. +function layer(baseDir: string) { + const layerEnvironment = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + DesktopConfig.layerTest({ T3CODE_HOME: baseDir }), + ), + ), + ); + return DesktopClientSettings.layer.pipe( + Layer.provideMerge(layerEnvironment), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); +} + +const withClientSettings = ( + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-client-settings-removed-keys-test-", + }); + return yield* effect.pipe(Effect.provide(layer(baseDir))); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped); + +const weights = { "environment-anchor": 100, "environment-other": 0 }; + +describe("DesktopClientSettings saved with a removed fork key", () => { + it.effect.each([ + { label: "direct", wrap: (settings: object) => settings }, + { label: "wrapped", wrap: (settings: object) => ({ settings }) }, + ])("loads a $label document and drops the key on the next save", ({ wrap }) => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + JSON.stringify( + wrap({ + anchorEnvironmentId: "environment-anchor", + loadBalancingEnabled: true, + loadBalancingWeights: weights, + }), + ), + ); + + const persisted = yield* settings.get; + assert.isTrue(Option.isSome(persisted)); + if (Option.isNone(persisted)) return; + assert.deepStrictEqual(persisted.value, { + ...DEFAULT_CLIENT_SETTINGS, + loadBalancingEnabled: true, + loadBalancingWeights: weights, + }); + + yield* settings.set(persisted.value); + const saved = yield* fileSystem.readFileString(environment.clientSettingsPath); + assert.notInclude(saved, "anchorEnvironmentId"); + }), + ), + ); +}); diff --git a/apps/web/src/clientPersistenceStorage.removedKeys.test.ts b/apps/web/src/clientPersistenceStorage.removedKeys.test.ts new file mode 100644 index 0000000000000..89460a7ab6f36 --- /dev/null +++ b/apps/web/src/clientPersistenceStorage.removedKeys.test.ts @@ -0,0 +1,61 @@ +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +// Fork-only. An earlier fork build saved `anchorEnvironmentId` in the client settings. +// The setting is gone; settings that still hold it must load (an invalid document throws +// on read), keep the user's other choices, and lose the key on the next save. +const STORAGE_KEY = "t3code:client-settings:v1"; + +function stubWindow(): Storage { + const store = new Map(); + const localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + } satisfies Storage; + vi.stubGlobal("window", { localStorage }); + vi.stubGlobal("localStorage", localStorage); + return localStorage; +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe("browser client settings saved with a removed fork key", () => { + const weights = { "environment-anchor": 100, "environment-other": 0 }; + + it.each([ + ["an environment id", "environment-anchor"], + ["null", null], + ["a non-string", 42], + ])("loads when the key holds %s and drops it on the next save", async (_label, anchor) => { + const storage = stubWindow(); + storage.setItem( + STORAGE_KEY, + JSON.stringify({ + anchorEnvironmentId: anchor, + loadBalancingEnabled: true, + loadBalancingWeights: weights, + }), + ); + const { readBrowserClientSettings, writeBrowserClientSettings } = + await import("./clientPersistenceStorage"); + + const settings = readBrowserClientSettings(); + expect(settings).toEqual({ + ...DEFAULT_CLIENT_SETTINGS, + loadBalancingEnabled: true, + loadBalancingWeights: weights, + }); + + writeBrowserClientSettings(settings!); + expect(JSON.parse(storage.getItem(STORAGE_KEY)!)).not.toHaveProperty("anchorEnvironmentId"); + }); +}); diff --git a/docs/fork/anchor-machine.md b/docs/fork/anchor-machine.md new file mode 100644 index 0000000000000..445b25162462a --- /dev/null +++ b/docs/fork/anchor-machine.md @@ -0,0 +1,55 @@ +# One machine for new work (an "anchor"), with upstream load balancing + +Fork note. No fork setting is needed: upstream's load balancing already expresses "start new threads of a shared +project on this machine". It does not cover threads without a project yet (see below). + +## Set it up (per client: web, desktop) + +1. **Settings → Environments → Load balancing**: turn on **Automatically balance load**. +2. Set the machine that should take new work to **Prefer**. +3. Set every other machine to **Manual only**. A machine you add later starts at **Normal** and joins automatic + selection, so set it to **Manual only** as well. + +New threads in a project that several machines share then start on the preferred machine. + +## Threads without a project: not covered yet + +Until upstream fixes its issue #31048, threads without a project ignore the **Prefer** and **Manual only** weights. +They start on the primary machine (or the machine you start them from), even when it is set to **Manual only**. The +composer offers no **Auto balance**, and **Send** goes to that machine without a warning. To use another machine, pick it in the composer; its "No +project" folder opens first. + +The fix exists as upstream PR #31049. The fork does not carry it yet, because it conflicts with upstream code that +is already released (upstream PR #35658). It is parked in +[scuttletoby/t3code#43](https://github.com/scuttletoby/t3code/pull/43) until upstream updates or merges it. + +## What happens when (threads in a shared project) + +| Situation | Result | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Preferred machine connected, with CPU and memory free | The draft shows **Auto balance** and moves there. | +| Preferred machine offline, or busy (CPU at 95% or more, or 5% memory or less) | The draft stays where it opened. **Send** refuses with "Choose a machine to continue"; pick a machine in the composer. | +| A file is attached before the draft has moved | The draft stays on the machine it opened on, and **Send** goes there, even if that machine is **Manual only** (upstream rule: attachments stay on their machine). | +| A thread is referenced that lives on a **Prefer** or **Normal** machine | That machine is the only one that can read it, so the draft moves there. | +| A thread is referenced that lives on a **Manual only** machine | No machine is picked. **Send** refuses with "Choose a machine to continue"; pick that machine in the composer. | +| A thread on another machine is referenced after the draft has moved | The draft does not move again. **Send** refuses with "Attached thread is on another machine"; remove the thread or pick its machine. | +| You pick a machine, branch or worktree by hand | That choice wins until you choose **Auto balance** again. | +| Only one machine has the project | No balancing; the draft uses that machine. | +| Load balancing off | Upstream's order: the primary machine first. | +| Thread without a project | Not balanced; see above. | + +## After upstream fixes #31048 + +Threads without a project will then follow the same weights. The preferred machine's "No project" folder opens +first, then the draft moves. One case can still send to a machine you did not choose: if that open fails or is cut +off (for example, the connection drops while it opens), the draft settles on the machine it is on. The composer +shows **Auto balance**, and **Send** goes to that machine even if it is **Manual only**. A failed open shows "Could +not switch machine"; a cut-off open shows nothing. Pick the machine by hand if it matters. This is upstream's +behaviour in #31049; the fork does not change it. + +## Removed fork setting + +An earlier draft of this fork change added a client setting `anchorEnvironmentId` (**Settings → Connections → +Anchor machine**). It was removed before it shipped. Settings saved with it still load: the key is ignored, the +other settings stay, and the next save drops it (tests `clientPersistenceStorage.removedKeys.test.ts` and +`DesktopClientSettings.removedKeys.test.ts`).