authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-06 02:33:13-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:33:13-03:00
log420fd76f60433fe05b8d2c76f4fbde430dc49968
tree04aec1843d95a1873aff936760a7ade23c0e4b15
parent9f40b2f563c662b43887b11ff99c466fe871c1af
signature Signed by PGP key B5690EEEBB952194

feat(connections): balance new threads across connected machines (#9895)


26 files changed, 865 insertions(+), 22 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+2
......@@ -58,6 +58,8 @@ const clientSettings: ClientSettings = {
5858 sidebarThreadSortOrder: "created_at",
5959 sidebarThreadPreviewCount: 6,
6060 legacySidebarEnabled: false,
61 loadBalancingEnabled: false,
62 loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
6163 timestampFormat: "24-hour",
6264 wordWrap: true,
6365};
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = {
5353 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
5454 [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
5555 [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
56 [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
5657 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
5758 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
5859 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
apps/server/src/resourceTelemetry/HostResources.ts created+93
......@@ -0,0 +1,93 @@
1import * as NodeOS from "node:os";
2import type { HostResourcesSnapshot } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Cache from "effect/Cache";
5import * as Context from "effect/Context";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11
12export class HostResources extends Context.Service<
13 HostResources,
14 { readonly read: Effect.Effect<HostResourcesSnapshot> }
15>()("t3/resourceTelemetry/HostResources") {}
16
17function readCpu() {
18 const cpus = NodeOS.cpus();
19 const cpu = cpus.reduce(
20 (sum, { times }) => ({
21 idle: sum.idle + times.idle,
22 total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq,
23 }),
24 { idle: 0, total: 0 },
25 );
26 return { ...cpu, count: cpus.length };
27}
28
29function darwinAvailableMemory(output: string): number | null {
30 const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
31 const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1];
32 const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1];
33 const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1];
34 if (!pageSize || !free || !inactive || !speculative) return null;
35 // vm_stat subtracts speculative pages from its printed "Pages free" count.
36 // Adding them here counts each reclaimable page once; purgeable pages overlap.
37 const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize);
38 return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null;
39}
40
41export const make = Effect.fn("makeHostResources")(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const platform = yield* HostProcessPlatform;
44 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
45
46 const sample = Effect.fn("HostResources.sample")(function* () {
47 const previousCpu = readCpu();
48 // CPU counters need two readings; idle servers do no polling or process scans.
49 yield* Effect.sleep("200 millis");
50 const cpu = readCpu();
51 const totalDelta = cpu.total - previousCpu.total;
52 const idleDelta = cpu.idle - previousCpu.idle;
53 const cpuUtilization =
54 previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0
55 ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta))
56 : null;
57 const totalMemoryBytes = NodeOS.totalmem();
58 // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory.
59 let availableMemoryBytes = NodeOS.freemem();
60 if (platform === "linux") {
61 const meminfo = yield* fs
62 .readFileString("/proc/meminfo")
63 .pipe(Effect.catch(() => Effect.succeed("")));
64 const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1];
65 if (available) availableMemoryBytes = Number(available) * 1024;
66 } else if (platform === "darwin") {
67 const output = yield* spawner
68 .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" }))
69 .pipe(
70 Effect.timeout("1 second"),
71 Effect.catch(() => Effect.succeed("")),
72 );
73 availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes;
74 }
75 return {
76 sampledAt: DateTime.toEpochMillis(yield* DateTime.now),
77 cpuUtilization,
78 cpuCount: cpu.count,
79 availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)),
80 totalMemoryBytes,
81 };
82 });
83
84 // One server-lifetime cache deduplicates simultaneous requests from all sockets.
85 const cache = yield* Cache.make({
86 capacity: 1,
87 lookup: (_key: "host") => sample(),
88 timeToLive: "5 seconds",
89 });
90 return HostResources.of({ read: Cache.get(cache, "host") });
91});
92
93export const layer = Layer.effect(HostResources, make());
apps/server/src/server.test.ts+92-2
......@@ -165,6 +165,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
165165import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
166166import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
167167import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
168import * as HostResources from "./resourceTelemetry/HostResources.ts";
168169import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
169170import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
170171import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
......@@ -845,7 +846,8 @@ const buildAppUnderTest = (options?: {
845846 }),
846847 }),
847848 ),
848 Layer.provide(
849 Layer.provide([
850 HostResources.layer,
849851 Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({
850852 readHistory: (input) =>
851853 Effect.succeed({
......@@ -860,7 +862,7 @@ const buildAppUnderTest = (options?: {
860862 error: Option.none(),
861863 }),
862864 }),
863 ),
865 ]),
864866 Layer.provide(
865867 Layer.mock(TraceDiagnostics.TraceDiagnostics)({
866868 read: () =>
......@@ -6154,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
61546156 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
61556157 );
61566158
6159 it.effect("returns cached whole-host resources over websocket", () =>
6160 Effect.gen(function* () {
6161 yield* buildAppUnderTest();
6162 const wsUrl = yield* getWsServerUrl("/ws");
6163 const [first, second] = yield* Effect.scoped(
6164 withWsRpcClient(wsUrl, (client) =>
6165 Effect.all(
6166 [
6167 client[WS_METHODS.serverGetHostResources]({}),
6168 client[WS_METHODS.serverGetHostResources]({}),
6169 ],
6170 { concurrency: "unbounded" },
6171 ),
6172 ),
6173 );
6174 assert.deepEqual(first, second);
6175 assert.isAtLeast(first.sampledAt, 0);
6176 assert.isAbove(first.cpuCount, 0);
6177 assert.isAbove(first.totalMemoryBytes, 0);
6178 assert.isAtLeast(first.availableMemoryBytes, 0);
6179 assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes);
6180 if (first.cpuUtilization !== null) {
6181 assert.isAtLeast(first.cpuUtilization, 0);
6182 assert.isAtMost(first.cpuUtilization, 1);
6183 }
6184 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
6185 );
6186
6187 it.effect("counts macOS reclaimable memory once and shares concurrent samples", () =>
6188 Effect.gen(function* () {
6189 const commandCalls = yield* Ref.make(0);
6190 const hostResources = yield* HostResources.make().pipe(
6191 Effect.provideService(HostProcessPlatform, "darwin"),
6192 Effect.provide(
6193 Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({
6194 string: () =>
6195 Ref.update(commandCalls, (count) => count + 1).pipe(
6196 Effect.as(
6197 "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" +
6198 "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" +
6199 "Pages purgeable: 999.\n",
6200 ),
6201 ),
6202 }),
6203 ),
6204 );
6205 const [first, second] = yield* Effect.all([hostResources.read, hostResources.read], {
6206 concurrency: "unbounded",
6207 });
6208 assert.equal(first.availableMemoryBytes, 35 * 16384);
6209 assert.deepEqual(first, second);
6210 assert.deepEqual(yield* hostResources.read, first);
6211 assert.equal(yield* Ref.get(commandCalls), 1);
6212 }).pipe(TestClock.withLive),
6213 );
6214
6215 it.effect("retries host sampling immediately after its caller is interrupted", () =>
6216 Effect.gen(function* () {
6217 const started = yield* Deferred.make<void>();
6218 const commandCalls = yield* Ref.make(0);
6219 const hostResources = yield* HostResources.make().pipe(
6220 Effect.provideService(HostProcessPlatform, "darwin"),
6221 Effect.provide(
6222 Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({
6223 string: () =>
6224 Effect.gen(function* () {
6225 const call = yield* Ref.updateAndGet(commandCalls, (count) => count + 1);
6226 if (call === 1) {
6227 yield* Deferred.succeed(started, undefined);
6228 return yield* Effect.never;
6229 }
6230 return (
6231 "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" +
6232 "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n"
6233 );
6234 }),
6235 }),
6236 ),
6237 );
6238 const firstRead = yield* hostResources.read.pipe(Effect.forkChild);
6239 yield* Deferred.await(started);
6240 yield* Fiber.interrupt(firstRead);
6241 const recovered = yield* hostResources.read;
6242 assert.equal(recovered.availableMemoryBytes, 35 * 4096);
6243 assert.equal(yield* Ref.get(commandCalls), 2);
6244 }).pipe(TestClock.withLive),
6245 );
6246
61576247 it.effect("routes websocket resource telemetry through the subscription", () =>
61586248 Effect.gen(function* () {
61596249 yield* buildAppUnderTest();
apps/server/src/server.ts+2
......@@ -115,6 +115,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
115115import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts";
116116import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
117117import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
118import * as HostResources from "./resourceTelemetry/HostResources.ts";
118119import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
119120import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
120121import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
......@@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
199200const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive));
200201
201202const ResourceDiagnosticsLayerLive = Layer.mergeAll(
203 HostResources.layer,
202204 ResourceTelemetryLayerLive,
203205 ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
204206 ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
apps/server/src/ws.ts+6
......@@ -135,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
135135import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
136136import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
137137import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
138import * as HostResources from "./resourceTelemetry/HostResources.ts";
138139import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
139140import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
140141import * as UsageService from "./usage/UsageService.ts";
......@@ -605,6 +606,7 @@ const makeWsRpcLayer = (
605606 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
606607 const sessions = yield* SessionStore.SessionStore;
607608 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
609 const hostResources = yield* HostResources.HostResources;
608610 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
609611 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
610612 const usage = yield* UsageService.UsageService;
......@@ -2020,6 +2022,10 @@ const makeWsRpcLayer = (
20202022 observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, {
20212023 "rpc.aggregate": "server",
20222024 }),
2025 [WS_METHODS.serverGetHostResources]: (_input) =>
2026 observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, {
2027 "rpc.aggregate": "server",
2028 }),
20232029 [WS_METHODS.serverGetProcessResourceHistory]: (input) =>
20242030 observeRpcEffect(
20252031 WS_METHODS.serverGetProcessResourceHistory,
apps/web/src/components/BranchToolbar.tsx+45-7
......@@ -6,6 +6,7 @@ import {
66 FolderGitIcon,
77 FolderIcon,
88 HistoryIcon,
9 ScaleIcon,
910} from "lucide-react";
1011import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1112
......@@ -56,6 +57,8 @@ interface BranchToolbarProps {
5657 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
5758 startFromOrigin: boolean;
5859 onStartFromOriginChange: (startFromOrigin: boolean) => void;
60 autoEnvironmentLabel?: string | undefined;
61 onAutoEnvironment?: (() => void) | undefined;
5962 envLocked: boolean;
6063 onCheckoutPullRequestRequest?: (reference: string) => void;
6164 onComposerFocusRequest?: () => void;
......@@ -66,6 +69,8 @@ interface BranchToolbarProps {
6669}
6770
6871interface MobileRunContextSelectorProps {
72 autoEnvironmentLabel?: string | undefined;
73 onAutoEnvironment?: (() => void) | undefined;
6974 envLocked: boolean;
7075 envModeLocked: boolean;
7176 environmentId: EnvironmentId;
......@@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps {
8186}
8287
8388const MobileRunContextSelector = memo(function MobileRunContextSelector({
89 autoEnvironmentLabel,
90 onAutoEnvironment,
8491 envLocked,
8592 envModeLocked,
8693 environmentId,
......@@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
114121 // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px
115122 // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px.
116123 <span className="inline-flex shrink-0 items-center gap-0.5">
117 <EnvironmentMachineIcon
118 kind={activeEnvironment?.machine ?? "server"}
119 className="size-3 shrink-0 mx-0!"
120 />
124 {autoEnvironmentLabel ? (
125 <ScaleIcon className="size-3 shrink-0 mx-0!" aria-hidden="true" />
126 ) : (
127 <EnvironmentMachineIcon
128 kind={activeEnvironment?.machine ?? "server"}
129 className="size-3 shrink-0 mx-0!"
130 />
131 )}
121132 <WorkspaceIcon className="size-3 shrink-0 mx-0!" />
122133 </span>
123134 ) : (
......@@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
134145 data-composer-label-motion
135146 className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
136147 >
137 {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}
148 {autoEnvironmentLabel ??
149 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}
138150 </span>
139151 </span>
140152 </>
......@@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
167179 <MenuGroup>
168180 <MenuGroupLabel>Run on</MenuGroupLabel>
169181 <MenuRadioGroup
170 value={environmentId}
171 onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}
182 value={autoEnvironmentLabel ? "auto" : environmentId}
183 onValueChange={(value) =>
184 value === "auto"
185 ? onAutoEnvironment?.()
186 : onEnvironmentChange(value as EnvironmentId)
187 }
172188 >
189 {onAutoEnvironment && (
190 <MenuRadioItem
191 value="auto"
192 disabled={envLocked}
193 onClick={() => {
194 if (autoEnvironmentLabel) onAutoEnvironment?.();
195 }}
196 >
197 <span className="flex min-w-0 items-center gap-1.5">
198 <ScaleIcon className="size-3" aria-hidden="true" />
199 <span className="min-w-0 truncate">
200 {autoEnvironmentLabel ?? "Auto balance"}
201 </span>
202 </span>
203 </MenuRadioItem>
204 )}
173205 {availableEnvironments.map((env) => (
174206 <MenuRadioItem
175207 key={env.environmentId}
......@@ -421,6 +453,8 @@ export const BranchToolbar = memo(function BranchToolbar({
421453 onActiveThreadBranchOverrideChange,
422454 startFromOrigin,
423455 onStartFromOriginChange,
456 autoEnvironmentLabel,
457 onAutoEnvironment,
424458 envLocked,
425459 onCheckoutPullRequestRequest,
426460 onComposerFocusRequest,
......@@ -518,6 +552,8 @@ export const BranchToolbar = memo(function BranchToolbar({
518552 {showGitControls ? (
519553 <div className="contents @3xl/composer-surface:hidden">
520554 <MobileRunContextSelector
555 autoEnvironmentLabel={autoEnvironmentLabel}
556 onAutoEnvironment={onAutoEnvironment}
521557 envLocked={envLocked}
522558 envModeLocked={envModeLocked}
523559 environmentId={environmentId}
......@@ -544,6 +580,8 @@ export const BranchToolbar = memo(function BranchToolbar({
544580 {showEnvironmentIndicator && availableEnvironments && (
545581 <>
546582 <BranchToolbarEnvironmentSelector
583 autoEnvironmentLabel={autoEnvironmentLabel}
584 onAutoEnvironment={onAutoEnvironment}
547585 envLocked={envLocked}
548586 environmentId={environmentId}
549587 availableEnvironments={availableEnvironments}
apps/web/src/components/BranchToolbarBranchSelector.tsx+4-2
......@@ -155,7 +155,7 @@ export function BranchToolbarBranchSelector({
155155 // Thread branch mutation (colocated — only this component calls it)
156156 // ---------------------------------------------------------------------------
157157 const setThreadBranch = useCallback(
158 (branch: string | null, worktreePath: string | null) => {
158 (branch: string | null, worktreePath: string | null, automatic = false) => {
159159 if (!activeThreadId || !activeProject) return;
160160 if (serverSession && worktreePath !== activeWorktreePath) {
161161 void stopThreadSession({
......@@ -186,6 +186,7 @@ export function BranchToolbarBranchSelector({
186186 branch,
187187 worktreePath,
188188 envMode: nextDraftEnvMode,
189 environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual",
189190 projectRef: scopeProjectRef(environmentId, activeProject.id),
190191 });
191192 },
......@@ -201,6 +202,7 @@ export function BranchToolbarBranchSelector({
201202 threadRef,
202203 environmentId,
203204 effectiveEnvMode,
205 draftThread?.environmentSelection,
204206 stopThreadSession,
205207 updateThreadMetadata,
206208 ],
......@@ -507,7 +509,7 @@ export function BranchToolbarBranchSelector({
507509 ) {
508510 return;
509511 }
510 setThreadBranch(worktreeBaseBranchCandidate, null);
512 setThreadBranch(worktreeBaseBranchCandidate, null, true);
511513 }, [
512514 activeThreadBranch,
513515 activeWorktreePath,
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+37-9
......@@ -1,4 +1,5 @@
11import type { EnvironmentId } from "@t3tools/contracts";
2import { ScaleIcon } from "lucide-react";
23import { memo, useMemo } from "react";
34
45import type { EnvironmentOption } from "./BranchToolbar.logic";
......@@ -15,6 +16,8 @@ import {
1516} from "./ui/select";
1617
1718interface BranchToolbarEnvironmentSelectorProps {
19 autoEnvironmentLabel?: string | undefined;
20 onAutoEnvironment?: (() => void) | undefined;
1821 envLocked: boolean;
1922 environmentId: EnvironmentId;
2023 availableEnvironments: readonly EnvironmentOption[];
......@@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps {
2427}
2528
2629export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({
30 autoEnvironmentLabel,
31 onAutoEnvironment,
2732 envLocked,
2833 environmentId,
2934 availableEnvironments,
......@@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
3439 }, [availableEnvironments, environmentId]);
3540
3641 const environmentItems = useMemo(
37 () =>
38 availableEnvironments.map((env) => ({
42 () => [
43 ...(onAutoEnvironment
44 ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }]
45 : []),
46 ...availableEnvironments.map((env) => ({
3947 value: env.environmentId,
4048 label: env.label,
4149 })),
42 [availableEnvironments],
50 ],
51 [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment],
4352 );
4453
4554 // The static label carries the xs control's height (h-7 sm:h-6) as well as
......@@ -75,8 +84,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
7584 return (
7685 <Select
7786 modal={false}
78 value={environmentId}
79 onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}
87 value={autoEnvironmentLabel ? "auto" : environmentId}
88 onValueChange={(value) =>
89 value === "auto" ? onAutoEnvironment?.() : onEnvironmentChange(value as EnvironmentId)
90 }
8091 items={environmentItems}
8192 >
8293 <SelectTrigger
......@@ -86,10 +97,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
8697 aria-label="Run on"
8798 data-composer-context-control
8899 >
89 <EnvironmentMachineIcon
90 kind={activeEnvironment?.machine ?? "server"}
91 className="size-3 shrink-0"
92 />
100 {autoEnvironmentLabel ? (
101 <ScaleIcon className="size-3 shrink-0" aria-hidden="true" />
102 ) : (
103 <EnvironmentMachineIcon
104 kind={activeEnvironment?.machine ?? "server"}
105 className="size-3 shrink-0"
106 />
107 )}
93108 <span
94109 data-composer-label
95110 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
......@@ -105,6 +120,19 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
105120 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
106121 <SelectGroup>
107122 <SelectGroupLabel>Run on</SelectGroupLabel>
123 {onAutoEnvironment && (
124 <SelectItem
125 value="auto"
126 onClick={() => {
127 if (autoEnvironmentLabel) onAutoEnvironment?.();
128 }}
129 >
130 <span className="inline-flex items-center gap-1.5">
131 <ScaleIcon className="size-3" aria-hidden="true" />
132 {autoEnvironmentLabel ?? "Auto balance"}
133 </span>
134 </SelectItem>
135 )}
108136 {availableEnvironments.map((env) => (
109137 <SelectItem key={env.environmentId} value={env.environmentId}>
110138 <span className="inline-flex items-center gap-1.5">
apps/web/src/components/ChatView.tsx+131
......@@ -1,3 +1,4 @@
1import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
12import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
23import {
34 collectProviderUsageLimits,
......@@ -3278,6 +3279,112 @@ export default function ChatView(props: ChatViewProps) {
32783279 (activeThread.session !== null && activeThread.session.status !== "stopped")),
32793280 );
32803281
3282 const loadBalancingSettings = useClientSettings();
3283 const automaticEnvironment = Boolean(
3284 clientSettingsHydrated &&
3285 draftId &&
3286 !envLocked &&
3287 hasMultipleEnvironments &&
3288 loadBalancingSettings.loadBalancingEnabled &&
3289 draftThread?.environmentSelection !== "manual" &&
3290 (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&
3291 (!draftThread?.branch || draftThread.environmentSelection === "auto") &&
3292 !draftThread?.worktreePath,
3293 );
3294 const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId;
3295 const loadBalancingCandidates = useMemo(
3296 () =>
3297 needsLoadBalancing
3298 ? logicalProjectEnvironments
3299 .filter((candidate) => {
3300 const environment = environmentById.get(candidate.environmentId);
3301 return (
3302 environment?.connection.phase === "connected" &&
3303 (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 &&
3304 environment.serverConfig?.providers.some(
3305 (provider) =>
3306 (activeProviderInstanceId === null ||
3307 provider.instanceId === activeProviderInstanceId) &&
3308 provider.driver === selectedProvider &&
3309 provider.enabled &&
3310 provider.installed &&
3311 provider.status !== "error" &&
3312 provider.auth.status !== "unauthenticated" &&
3313 provider.availability !== "unavailable",
3314 )
3315 );
3316 })
3317 .map((candidate) => candidate.environmentId)
3318 : [],
3319 [
3320 needsLoadBalancing,
3321 logicalProjectEnvironments,
3322 environmentById,
3323 loadBalancingSettings.loadBalancingWeights,
3324 activeProviderInstanceId,
3325 selectedProvider,
3326 ],
3327 );
3328 const loadBalancing = useLoadBalancedEnvironment(
3329 loadBalancingCandidates,
3330 loadBalancingSettings.loadBalancingWeights,
3331 );
3332 useEffect(() => {
3333 if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return;
3334 const target = logicalProjectEnvironments.find(
3335 (environment) => environment.environmentId === loadBalancing.environmentId,
3336 );
3337 if (!target) return;
3338 setDraftThreadContext(draftId, {
3339 projectRef: scopeProjectRef(target.environmentId, target.projectId),
3340 environmentSelection: "auto",
3341 loadBalancedEnvironmentId: target.environmentId,
3342 });
3343 }, [
3344 needsLoadBalancing,
3345 loadBalancing.pending,
3346 loadBalancing.environmentId,
3347 draftId,
3348 logicalProjectEnvironments,
3349 setDraftThreadContext,
3350 ]);
3351 const onAutoEnvironment = useCallback(() => {
3352 if (envLocked || !draftId) return;
3353 if (composerHasAttachments) {
3354 toastManager.add({
3355 type: "warning",
3356 id: "load-balancing-attachments",
3357 title: "Keep attachments on this machine",
3358 description:
3359 "Remove attachments before choosing automatic routing, then attach them on the selected machine.",
3360 });
3361 return;
3362 }
3363 loadBalancing.refresh(
3364 logicalProjectEnvironments.map((environment) => environment.environmentId),
3365 );
3366 setDraftThreadContext(draftId, {
3367 environmentSelection: "auto",
3368 loadBalancedEnvironmentId: null,
3369 branch: null,
3370 worktreePath: null,
3371 });
3372 }, [
3373 envLocked,
3374 draftId,
3375 setDraftThreadContext,
3376 loadBalancing.refresh,
3377 logicalProjectEnvironments,
3378 composerHasAttachments,
3379 ]);
3380 const autoEnvironmentLabel = automaticEnvironment
3381 ? draftThread?.loadBalancedEnvironmentId
3382 ? "Auto balance"
3383 : loadBalancing.pending
3384 ? "Checking machines…"
3385 : "Auto balance unavailable"
3386 : undefined;
3387
32813388 // Handle environment change for draft threads. When the user picks a
32823389 // different environment we update the draft context to point at the physical
32833390 // project in that environment while keeping the same logical project.
......@@ -3290,6 +3397,8 @@ export default function ChatView(props: ChatViewProps) {
32903397 if (!target) return;
32913398 setDraftThreadContext(draftId, {
32923399 projectRef: scopeProjectRef(target.environmentId, target.projectId),
3400 environmentSelection: "manual",
3401 loadBalancedEnvironmentId: null,
32933402 });
32943403 },
32953404 [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext],
......@@ -6249,6 +6358,7 @@ export default function ChatView(props: ChatViewProps) {
62496358 !activeThread ||
62506359 isSendBusy ||
62516360 isConnecting ||
6361 !clientSettingsHydrated ||
62526362 threadDetailLoading ||
62536363 sendInFlightRef.current ||
62546364 feedbackUploadsInFlightRef.current.has(routeThreadKey)
......@@ -6256,6 +6366,18 @@ export default function ChatView(props: ChatViewProps) {
62566366 notifyDirectAnnotationAttached();
62576367 return;
62586368 }
6369 if (needsLoadBalancing) {
6370 toastManager.add({
6371 type: "warning",
6372 title: loadBalancing.pending
6373 ? "Checking machine resources"
6374 : "Choose a machine to continue",
6375 description: loadBalancing.pending
6376 ? "Resource checks are still running. You can choose a machine in the composer."
6377 : "No eligible machine has available resources. Choose a machine in the composer to override.",
6378 });
6379 return;
6380 }
62596381 if (activeEnvironmentUnavailable) {
62606382 const toastSlot = environmentUnavailableSendToastSlotRef.current;
62616383 environmentUnavailableSendToastSlotRef.current =
......@@ -8231,6 +8353,15 @@ export default function ChatView(props: ChatViewProps) {
82318353 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
82328354 : {})}
82338355 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
8356 autoEnvironmentLabel={autoEnvironmentLabel}
8357 onAutoEnvironment={
8358 draftId &&
8359 !envLocked &&
8360 hasMultipleEnvironments &&
8361 loadBalancingSettings.loadBalancingEnabled
8362 ? onAutoEnvironment
8363 : undefined
8364 }
82348365 availableEnvironments={logicalProjectEnvironments}
82358366 composerControlsHostRef={setRestingComposerControlsHost}
82368367 contextStripVisible={showComposerContextStrip}
apps/web/src/components/GitActionsControl.tsx+6-2
......@@ -1042,7 +1042,7 @@ export default function GitActionsControl({
10421042 }, []);
10431043
10441044 const persistThreadBranchSync = useCallback(
1045 (branch: string | null) => {
1045 (branch: string | null, manualSelection = false) => {
10461046 if (!activeThreadRef) {
10471047 return;
10481048 }
......@@ -1070,6 +1070,10 @@ export default function GitActionsControl({
10701070 setDraftThreadContext(draftId ?? activeThreadRef, {
10711071 branch,
10721072 worktreePath: activeDraftThread.worktreePath,
1073 environmentSelection: manualSelection
1074 ? "manual"
1075 : (activeDraftThread.environmentSelection ??
1076 (activeDraftThread.branch ? "manual" : "auto")),
10731077 });
10741078 },
10751079 [
......@@ -1089,7 +1093,7 @@ export default function GitActionsControl({
10891093 return;
10901094 }
10911095
1092 persistThreadBranchSync(branchUpdate.branch);
1096 persistThreadBranchSync(branchUpdate.branch, true);
10931097 },
10941098 [persistThreadBranchSync],
10951099 );
apps/web/src/components/settings/ConnectionsSettings.tsx+2
......@@ -60,6 +60,7 @@ import {
6060} from "./settingsLayout";
6161import { searchableSetting } from "./settingsSearch";
6262import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
63import { LoadBalancingSettings } from "./LoadBalancingSettings";
6364import { Input } from "../ui/input";
6465import { CommandShortcut } from "../ui/command";
6566import {
......@@ -3592,6 +3593,7 @@ export function ConnectionsSettings() {
35923593 savedEnvironments={savedEnvironments}
35933594 />
35943595 </SettingsSection>
3596 <LoadBalancingSettings environments={environments} />
35953597 </SettingsPageContainer>
35963598 );
35973599}
apps/web/src/components/settings/LoadBalancingSettings.tsx created+94
......@@ -0,0 +1,94 @@
1import { connectionStatusText } from "@t3tools/client-runtime/connection";
2
3import {
4 useClientSettings,
5 useClientSettingsHydrated,
6 useUpdateClientSettings,
7} from "~/hooks/useSettings";
8import type { EnvironmentPresentation } from "~/state/environments";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { Switch } from "../ui/switch";
11import { SettingsRow, SettingsSection } from "./settingsLayout";
12import { searchableSetting } from "./settingsSearch";
13
14const preferences = [
15 { value: 100, label: "Prefer" },
16 { value: 50, label: "Normal" },
17 { value: 25, label: "Less often" },
18 { value: 0, label: "Manual only" },
19];
20
21export function LoadBalancingSettings({
22 environments,
23}: {
24 environments: ReadonlyArray<EnvironmentPresentation>;
25}) {
26 const settings = useClientSettings();
27 const settingsHydrated = useClientSettingsHydrated();
28 const updateSettings = useUpdateClientSettings();
29
30 return (
31 <SettingsSection
32 {...searchableSetting("load-balancing")}
33 description="Choose how often each machine is used. Prefer gives a machine more work when it has capacity; Less often gives it less. Manual only excludes it from automatic selection. Preferences are saved for this client."
34 >
35 <SettingsRow
36 title="Automatically balance load"
37 description="Automatically choose a connected machine for new threads in shared projects. You can choose a machine in the composer."
38 control={
39 <Switch
40 aria-label="Automatically balance load"
41 checked={settings.loadBalancingEnabled}
42 disabled={!settingsHydrated}
43 onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })}
44 />
45 }
46 />
47 {environments.map((environment) => {
48 const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50;
49 // Keep saved slider weights until the user chooses a different preference.
50 const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100;
51
52 return (
53 <SettingsRow
54 key={environment.environmentId}
55 title={environment.label}
56 description={connectionStatusText(environment.connection)}
57 control={
58 <Select
59 items={preferences}
60 value={preference}
61 disabled={!settingsHydrated || !settings.loadBalancingEnabled}
62 onValueChange={(value) => {
63 if (value !== null) {
64 updateSettings({
65 loadBalancingWeights: {
66 ...settings.loadBalancingWeights,
67 [environment.environmentId]: value,
68 },
69 });
70 }
71 }}
72 >
73 <SelectTrigger
74 size="sm"
75 className="w-full sm:w-40"
76 aria-label={`${environment.label} load preference`}
77 >
78 <SelectValue />
79 </SelectTrigger>
80 <SelectPopup align="end" alignItemWithTrigger={false}>
81 {preferences.map(({ value, label }) => (
82 <SelectItem key={value} value={value}>
83 {label}
84 </SelectItem>
85 ))}
86 </SelectPopup>
87 </Select>
88 }
89 />
90 );
91 })}
92 </SettingsSection>
93 );
94}
apps/web/src/components/settings/settingsSearch.ts+8
......@@ -514,6 +514,14 @@ export const SETTINGS_SEARCH_ITEMS = [
514514 to: "/settings/connections",
515515 searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"],
516516 },
517 {
518 id: "load-balancing",
519 title: "Load balancing",
520 to: "/settings/connections",
521 searchTerms: [
522 "automatic machine environment resources cpu memory capacity preference weight shared projects",
523 ],
524 },
517525 {
518526 id: "archive",
519527 title: "Archived threads",
apps/web/src/composerDraftStore.test.ts+57
......@@ -1623,6 +1623,63 @@ describe("composerDraftStore project draft thread mapping", () => {
16231623 expect(file && composerFileNeedsReattach(file)).toBe(true);
16241624 });
16251625
1626 it("rechecks balancing when an empty draft is remapped to another project member", () => {
1627 const store = useComposerDraftStore.getState();
1628 store.setProjectDraftThreadId(projectRef, draftId, {
1629 threadId,
1630 environmentSelection: "auto",
1631 loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID,
1632 });
1633 store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId });
1634 expect(store.getDraftThread(draftId)).toMatchObject({
1635 environmentSelection: "auto",
1636 loadBalancedEnvironmentId: null,
1637 });
1638 store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID });
1639 store.setDraftThreadContext(draftId, { projectRef });
1640 expect(store.getDraftThread(draftId)).toMatchObject({
1641 environmentSelection: "auto",
1642 loadBalancedEnvironmentId: null,
1643 });
1644 });
1645
1646 it("does not opt a legacy branch choice into balancing when runtime mode changes", () => {
1647 const store = useComposerDraftStore.getState();
1648 store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" });
1649 store.setDraftThreadContext(draftId, { runtimeMode: "full-access" });
1650 expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined();
1651 expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned");
1652 });
1653
1654 it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => {
1655 const store = useComposerDraftStore.getState();
1656 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1657 store.setPrompt(draftId, "keep this prompt");
1658 store.setDraftThreadContext(draftId, {
1659 projectRef: remoteProjectRef,
1660 environmentSelection: "auto",
1661 loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID,
1662 });
1663 expect(store.getDraftThread(draftId)).toMatchObject({
1664 environmentId: OTHER_TEST_ENVIRONMENT_ID,
1665 environmentSelection: "auto",
1666 loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID,
1667 });
1668 store.setDraftThreadContext(draftId, { branch: "feature/pinned" });
1669 expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual");
1670 store.setDraftThreadContext(draftId, {
1671 branch: null,
1672 environmentSelection: "auto",
1673 loadBalancedEnvironmentId: null,
1674 });
1675 expect(store.getDraftThread(draftId)).toMatchObject({
1676 branch: null,
1677 environmentSelection: "auto",
1678 loadBalancedEnvironmentId: null,
1679 });
1680 expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt");
1681 });
1682
16261683 it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => {
16271684 const store = useComposerDraftStore.getState();
16281685 store.setProjectDraftThreadId(projectRef, draftId, {
apps/web/src/composerDraftStore.ts+59
......@@ -315,6 +315,8 @@ const PersistedDraftThreadState = Schema.Struct({
315315 environmentId: Schema.String,
316316 projectId: ProjectId,
317317 logicalProjectKey: Schema.optionalKey(Schema.String),
318 environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])),
319 loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)),
318320 createdAt: Schema.String,
319321 runtimeMode: RuntimeMode,
320322 interactionMode: ProviderInteractionMode,
......@@ -427,6 +429,8 @@ export interface DraftSessionState {
427429 environmentId: EnvironmentId;
428430 projectId: ProjectId;
429431 logicalProjectKey: string;
432 environmentSelection?: "auto" | "manual";
433 loadBalancedEnvironmentId?: EnvironmentId | null;
430434 createdAt: string;
431435 runtimeMode: RuntimeMode;
432436 interactionMode: ProviderInteractionMode;
......@@ -503,6 +507,8 @@ interface ComposerDraftStoreState {
503507 startFromOrigin?: boolean;
504508 runtimeMode?: RuntimeMode;
505509 interactionMode?: ProviderInteractionMode;
510 environmentSelection?: "auto" | "manual";
511 loadBalancedEnvironmentId?: EnvironmentId | null;
506512 },
507513 ) => void;
508514 /** Creates or updates the draft session tracked for a concrete project ref. */
......@@ -518,6 +524,8 @@ interface ComposerDraftStoreState {
518524 startFromOrigin?: boolean;
519525 runtimeMode?: RuntimeMode;
520526 interactionMode?: ProviderInteractionMode;
527 environmentSelection?: "auto" | "manual";
528 loadBalancedEnvironmentId?: EnvironmentId | null;
521529 },
522530 ) => void;
523531 /** Updates mutable draft-session metadata without touching composer content. */
......@@ -532,6 +540,8 @@ interface ComposerDraftStoreState {
532540 startFromOrigin?: boolean;
533541 runtimeMode?: RuntimeMode;
534542 interactionMode?: ProviderInteractionMode;
543 environmentSelection?: "auto" | "manual";
544 loadBalancedEnvironmentId?: EnvironmentId | null;
535545 },
536546 ) => void;
537547 clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void;
......@@ -1534,6 +1544,8 @@ function createDraftThreadState(
15341544 startFromOrigin?: boolean;
15351545 runtimeMode?: RuntimeMode;
15361546 interactionMode?: ProviderInteractionMode;
1547 environmentSelection?: "auto" | "manual";
1548 loadBalancedEnvironmentId?: EnvironmentId | null;
15371549 },
15381550): DraftThreadState {
15391551 // A project change (including switching environments within a logical
......@@ -1560,11 +1572,23 @@ function createDraftThreadState(
15601572 options?.startFromOrigin === undefined
15611573 ? (existingThread?.startFromOrigin ?? false)
15621574 : options.startFromOrigin;
1575 const environmentSelection =
1576 options?.environmentSelection ?? existingThread?.environmentSelection;
15631577 return {
15641578 threadId,
15651579 environmentId: projectRef.environmentId,
15661580 projectId: projectRef.projectId,
15671581 logicalProjectKey,
1582 ...(environmentSelection ? { environmentSelection } : {}),
1583 ...(options?.loadBalancedEnvironmentId !== undefined
1584 ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId }
1585 : existingThread?.loadBalancedEnvironmentId !== undefined
1586 ? {
1587 loadBalancedEnvironmentId: projectChanged
1588 ? null
1589 : existingThread.loadBalancedEnvironmentId,
1590 }
1591 : {}),
15681592 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
15691593 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
15701594 interactionMode:
......@@ -1599,6 +1623,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea
15991623 left.environmentId === right.environmentId &&
16001624 left.projectId === right.projectId &&
16011625 left.logicalProjectKey === right.logicalProjectKey &&
1626 left.environmentSelection === right.environmentSelection &&
1627 left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId &&
16021628 left.createdAt === right.createdAt &&
16031629 left.runtimeMode === right.runtimeMode &&
16041630 left.interactionMode === right.interactionMode &&
......@@ -1754,6 +1780,16 @@ function normalizePersistedDraftThreads(
17541780 worktreePath: normalizedWorktreePath,
17551781 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
17561782 startFromOrigin,
1783 ...(candidateDraftThread.environmentSelection === "manual" ||
1784 candidateDraftThread.environmentSelection === "auto"
1785 ? { environmentSelection: candidateDraftThread.environmentSelection }
1786 : {}),
1787 ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" &&
1788 candidateDraftThread.loadBalancedEnvironmentId.length > 0
1789 ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId }
1790 : candidateDraftThread.loadBalancedEnvironmentId === null
1791 ? { loadBalancedEnvironmentId: null }
1792 : {}),
17571793 promotedTo,
17581794 };
17591795 }
......@@ -2453,6 +2489,15 @@ function toHydratedDraftThreadState(
24532489 worktreePath: persistedDraftThread.worktreePath,
24542490 envMode: persistedDraftThread.envMode,
24552491 startFromOrigin: persistedDraftThread.startFromOrigin,
2492 ...(persistedDraftThread.environmentSelection
2493 ? { environmentSelection: persistedDraftThread.environmentSelection }
2494 : {}),
2495 ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined
2496 ? {
2497 loadBalancedEnvironmentId:
2498 persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null,
2499 }
2500 : {}),
24562501 promotedTo: persistedDraftThread.promotedTo
24572502 ? scopeThreadRef(
24582503 persistedDraftThread.promotedTo.environmentId as EnvironmentId,
......@@ -2707,11 +2752,23 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
27072752 options.startFromOrigin === undefined
27082753 ? existing.startFromOrigin
27092754 : options.startFromOrigin;
2755 const environmentSelection =
2756 options.environmentSelection ??
2757 (options.branch != null || options.worktreePath != null
2758 ? "manual"
2759 : existing.environmentSelection);
27102760 const nextDraftThread: DraftThreadState = {
27112761 threadId: existing.threadId,
27122762 environmentId: nextProjectRef.environmentId,
27132763 projectId: nextProjectRef.projectId,
27142764 logicalProjectKey: existing.logicalProjectKey,
2765 ...(environmentSelection ? { environmentSelection } : {}),
2766 loadBalancedEnvironmentId:
2767 options.loadBalancedEnvironmentId === undefined
2768 ? projectChanged
2769 ? null
2770 : (existing.loadBalancedEnvironmentId ?? null)
2771 : options.loadBalancedEnvironmentId,
27152772 createdAt:
27162773 options.createdAt === undefined
27172774 ? existing.createdAt
......@@ -2729,6 +2786,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
27292786 nextDraftThread.environmentId === existing.environmentId &&
27302787 nextDraftThread.projectId === existing.projectId &&
27312788 nextDraftThread.logicalProjectKey === existing.logicalProjectKey &&
2789 nextDraftThread.environmentSelection === existing.environmentSelection &&
2790 nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId &&
27322791 nextDraftThread.createdAt === existing.createdAt &&
27332792 nextDraftThread.runtimeMode === existing.runtimeMode &&
27342793 nextDraftThread.interactionMode === existing.interactionMode &&
apps/web/src/hooks/useLoadBalancedEnvironment.ts created+50
......@@ -0,0 +1,50 @@
1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { Atom } from "effect/unstable/reactivity";
5import { useCallback, useContext, useMemo } from "react";
6
7import { serverEnvironment } from "../state/server";
8
9/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */
10export function useLoadBalancedEnvironment(
11 environmentIds: readonly EnvironmentId[],
12 weights: Readonly<Record<string, number>>,
13) {
14 const registry = useContext(RegistryContext);
15 const refresh = useCallback(
16 (ids: readonly EnvironmentId[]) => {
17 for (const environmentId of ids) {
18 registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} }));
19 }
20 },
21 [registry],
22 );
23 const resourcesAtom = useMemo(
24 () =>
25 Atom.make((get) =>
26 environmentIds.map((environmentId) => {
27 const result = get(serverEnvironment.hostResources({ environmentId, input: {} }));
28 return {
29 environmentId,
30 resources: result._tag === "Success" ? result.value : null,
31 receivedAt: result._tag === "Success" ? result.timestamp : 0,
32 pending: result._tag === "Initial" || result.waiting,
33 };
34 }),
35 ),
36 [environmentIds],
37 );
38 const resources = useAtomValue(resourcesAtom);
39 return {
40 refresh,
41 pending: resources.some((resource) => resource.pending),
42 environmentId: chooseLoadBalancedEnvironment(
43 resources.map((resource) => ({
44 ...resource,
45 weight: weights[resource.environmentId] ?? 50,
46 })),
47 Date.now(),
48 ) as EnvironmentId | null,
49 };
50}
docs/user/remote-access.md+17
......@@ -61,6 +61,23 @@ created in Settings can only be copied from the client that created them while
6161its Connections page stays open. If you leave or reload that page, create
6262another link to share.
6363
64### Balance new threads across machines
65
66Auto balance is off by default. On web and desktop, enable it in
67**Settings → Connections → Load balancing** to automatically choose a machine for
68new threads in projects grouped across connected environments.
69Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and
70memory available, **Less often** to reduce its share, or **Manual only** to exclude
71it from automatic selection. These are preferences, not fixed traffic percentages.
72Preferences are saved separately in each client.
73
74The composer checks eligible machines when choosing a draft's environment, then keeps
75that choice stable. Choose **Auto balance** again to check current resources, or choose
76a specific machine to override it. Choosing a branch or worktree also keeps the draft
77on that machine. Existing threads stay where they started. If resource checks are
78unavailable or all eligible machines are full, choose a machine manually to continue.
79Mobile keeps its manual environment selection.
80
6481### Tailscale HTTPS
6582
6683Join both devices to the same tailnet. In the desktop app, enable **Tailscale
packages/client-runtime/package.json+4
......@@ -3,6 +3,10 @@
33 "private": true,
44 "type": "module",
55 "exports": {
6 "./load-balancing": {
7 "types": "./src/load-balancing.ts",
8 "default": "./src/load-balancing.ts"
9 },
610 "./project-favicon-cache": {
711 "types": "./src/projectFaviconCache.ts",
812 "default": "./src/projectFaviconCache.ts"
packages/client-runtime/src/load-balancing.ts created+40
......@@ -0,0 +1,40 @@
1import type { HostResourcesSnapshot } from "@t3tools/contracts";
2
3/** Callers supply only connected machines hosting the project and selected provider. */
4export function chooseLoadBalancedEnvironment(
5 candidates: ReadonlyArray<{
6 environmentId: string;
7 resources: HostResourcesSnapshot | null;
8 /** Client receipt time avoids comparing clocks on different machines. */
9 receivedAt?: number;
10 weight: number;
11 }>,
12 now: number,
13): string | null {
14 let selected: string | null = null;
15 let bestScore = 0;
16 for (const { environmentId, resources, receivedAt, weight } of candidates) {
17 const sampledAt = receivedAt ?? resources?.sampledAt ?? 0;
18 if (
19 !resources ||
20 !Number.isFinite(weight) ||
21 weight <= 0 ||
22 now - sampledAt > 15_000 ||
23 sampledAt > now + 5_000 ||
24 resources.cpuUtilization === null ||
25 resources.cpuUtilization >= 0.95 ||
26 resources.totalMemoryBytes <= 0 ||
27 resources.cpuCount <= 0
28 ) {
29 continue;
30 }
31 const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes;
32 if (memoryAvailable <= 0.05) continue;
33 const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable;
34 if (score > bestScore) {
35 selected = environmentId;
36 bestScore = score;
37 }
38 }
39 return selected;
40}
packages/client-runtime/src/state/projectGrouping.test.ts+65
......@@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
44import type { EnvironmentProject } from "./models.ts";
5import { chooseLoadBalancedEnvironment } from "../load-balancing.ts";
56import {
67 buildProjectGroups,
78 derivePhysicalProjectKey,
......@@ -9,6 +10,70 @@ import {
910} from "./projectGrouping.ts";
1011
1112const environmentId = EnvironmentId.make("environment");
13
14describe("load balancing shared project machines", () => {
15 const now = 100_000;
16 const resources = {
17 sampledAt: now,
18 cpuUtilization: 0.2,
19 cpuCount: 8,
20 availableMemoryBytes: 8_000,
21 totalMemoryBytes: 16_000,
22 };
23
24 it("compares three machines using free capacity and preference", () => {
25 const candidates = [
26 { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 },
27 { environmentId: "idle", resources, weight: 1 },
28 { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 },
29 ];
30 expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred");
31 expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle");
32 });
33
34 it("rejects stale, unknown, excluded and saturated machines", () => {
35 expect(
36 chooseLoadBalancedEnvironment(
37 [
38 {
39 environmentId: "stale",
40 resources: { ...resources, sampledAt: now - 15_001 },
41 weight: 1,
42 },
43 { environmentId: "unknown", resources: null, weight: 1 },
44 {
45 environmentId: "no-cpu-sample",
46 resources: { ...resources, cpuUtilization: null },
47 weight: 1,
48 },
49 { environmentId: "excluded", resources, weight: 0 },
50 {
51 environmentId: "cpu-full",
52 resources: { ...resources, cpuUtilization: 0.95 },
53 weight: 1,
54 },
55 {
56 environmentId: "memory-full",
57 resources: { ...resources, availableMemoryBytes: 100 },
58 weight: 1,
59 },
60 ],
61 now,
62 ),
63 ).toBeNull();
64 });
65
66 it("uses client receipt time when host clocks differ", () => {
67 const candidate = {
68 environmentId: "different-clock",
69 resources: { ...resources, sampledAt: now + 60_000 },
70 receivedAt: now,
71 weight: 1,
72 };
73 expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock");
74 expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull();
75 });
76});
1277const repositoryIdentity = {
1378 canonicalKey: "github.com/t3tools/t3code",
1479 locator: {
packages/client-runtime/src/state/server.ts+7
......@@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
2626import {
2727 createAtomCommandScheduler,
2828 createEnvironmentRpcCommand,
29 createEnvironmentQueryAtomFamily,
2930 createEnvironmentRpcQueryAtomFamily,
3031 createEnvironmentRpcSubscriptionAtomFamily,
3132 createRuntimeCommand,
......@@ -1018,6 +1019,12 @@ export function createServerEnvironmentAtoms<R, E>(
10181019 label: "environment-data:server:process-diagnostics",
10191020 tag: WS_METHODS.serverGetProcessDiagnostics,
10201021 }),
1022 hostResources: createEnvironmentQueryAtomFamily(runtime, {
1023 label: "environment-data:server:host-resources",
1024 staleTimeMs: 5_000,
1025 execute: (input: EnvironmentRpcInput<typeof WS_METHODS.serverGetHostResources>) =>
1026 request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")),
1027 }),
10211028 processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, {
10221029 label: "environment-data:server:process-resource-history",
10231030 tag: WS_METHODS.serverGetProcessResourceHistory,
packages/contracts/src/resourceTelemetry.ts+10
......@@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts";
66
77export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;
88
9/** Whole-host capacity, independent of T3's process diagnostics. */
10export const HostResourcesSnapshot = Schema.Struct({
11 sampledAt: NonNegativeInt,
12 cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))),
13 cpuCount: NonNegativeInt,
14 availableMemoryBytes: NonNegativeInt,
15 totalMemoryBytes: NonNegativeInt,
16});
17export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type;
18
919export const ResourceTelemetryIoSemantics = Schema.Literals([
1020 "storage",
1121 "logical",
packages/contracts/src/rpc.ts+9
......@@ -210,6 +210,7 @@ import {
210210 ServerUpsertKeybindingResult,
211211} from "./server.ts";
212212import {
213 HostResourcesSnapshot,
213214 ResourceTelemetryHistory,
214215 ResourceTelemetryHistoryInput,
215216 ResourceTelemetryRetryResult,
......@@ -323,6 +324,7 @@ export const WS_METHODS = {
323324 serverDiscoverSourceControl: "server.discoverSourceControl",
324325 serverGetTraceDiagnostics: "server.getTraceDiagnostics",
325326 serverGetProcessDiagnostics: "server.getProcessDiagnostics",
327 serverGetHostResources: "server.getHostResources",
326328 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
327329 serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
328330 serverRetryResourceTelemetry: "server.retryResourceTelemetry",
......@@ -538,6 +540,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia
538540 error: EnvironmentAuthorizationError,
539541});
540542
543const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, {
544 payload: Schema.Struct({}),
545 success: HostResourcesSnapshot,
546 error: EnvironmentAuthorizationError,
547});
548
541549const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, {
542550 payload: ServerProcessResourceHistoryInput,
543551 success: ServerProcessResourceHistoryResult,
......@@ -1197,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make(
11971205 WsServerDiscoverSourceControlRpc,
11981206 WsServerGetTraceDiagnosticsRpc,
11991207 WsServerGetProcessDiagnosticsRpc,
1208 WsServerGetHostResourcesRpc,
12001209 WsServerGetProcessResourceHistoryRpc,
12011210 WsServerGetResourceTelemetryHistoryRpc,
12021211 WsServerRetryResourceTelemetryRpc,
packages/contracts/src/settings.test.ts+15
......@@ -135,6 +135,21 @@ describe("ClaudeSettings auto-compaction", () => {
135135 });
136136});
137137
138describe("ClientSettings load balancing", () => {
139 it("requires opt-in when settings are new or omit load balancing", () => {
140 expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false);
141 expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false);
142 });
143
144 it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => {
145 const settings = decodeClientSettings({ loadBalancingEnabled });
146 expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled);
147 expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe(
148 loadBalancingEnabled,
149 );
150 });
151});
152
138153describe("ClientSettings word wrap", () => {
139154 it("defaults word wrap on", () => {
140155 expect(decodeClientSettings({}).wordWrap).toBe(true);
packages/contracts/src/settings.ts+9
......@@ -204,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]);
204204export type BrowserLinkTarget = typeof BrowserLinkTarget.Type;
205205export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system";
206206
207export const LoadBalancingWeights = Schema.Record(
208 TrimmedNonEmptyString,
209 Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
210);
211
207212export const ClientSettingsSchema = Schema.Struct({
213 loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
214 loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
208215 appearanceContrast: AppearanceContrast.pipe(
209216 Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
210217 ),
......@@ -1210,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({
12101217export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
12111218
12121219export const ClientSettingsPatch = Schema.Struct({
1220 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
1221 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
12131222 appearanceContrast: Schema.optionalKey(AppearanceContrast),
12141223 panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs),
12151224 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),