From 4092c4ac567b791c84c20a15403f2b7d5051d42d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 12:49:29 -0700 Subject: [PATCH 01/21] fix(client): retry the permissions check after a reconnect (#27008) Co-authored-by: Claude Opus 5.5 --- .../settings/EnvironmentRoutesSection.tsx | 15 +- .../SettingsEnvironmentDetailRouteScreen.tsx | 14 +- .../settings/SessionPermissions.tsx | 11 +- .../client-runtime/src/state/session.test.ts | 133 ++++++++++++++++++ packages/client-runtime/src/state/session.ts | 57 +++++++- packages/shared/src/authScopeOptions.ts | 16 +++ 6 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 packages/client-runtime/src/state/session.test.ts diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 012d00737d1df..842965dbf1575 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -12,7 +12,10 @@ import { isLearned, } from "@t3tools/client-runtime/connection"; import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; -import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; +import { + AUTH_SCOPE_OPTIONS, + sessionPermissionsUnavailableMessage, +} from "@t3tools/shared/authScopeOptions"; import { AsyncResult } from "effect/reactivity"; import * as Option from "effect/Option"; import { useState } from "react"; @@ -163,8 +166,14 @@ export function EnvironmentRoutesSection({ ) : ( - Permissions not checked. Connect to this environment to view this session’s - permissions. + {sessionPermissionsUnavailableMessage({ + connected: activeRouteId !== null, + failed: sessionResult._tag === "Failure", + rejected: + sessionResult._tag !== "Failure" && + !sessionResult.waiting && + session?.authenticated === false, + })} )} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index cd9d8e03c9c1f..c581cbc688142 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -8,6 +8,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { sessionPermissionsUnavailableMessage } from "@t3tools/shared/authScopeOptions"; import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import { AsyncResult } from "effect/reactivity"; @@ -208,11 +209,14 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm ) : !allowed ? ( - {AsyncResult.isFailure(sessionResult) - ? "Could not verify your permissions. Reconnect to try again." - : session === null - ? "Checking permissions…" - : "This connection does not have permission to manage the environment."} + {AsyncResult.isFailure(sessionResult) || session === null || !session.authenticated + ? sessionPermissionsUnavailableMessage({ + connected, + failed: AsyncResult.isFailure(sessionResult), + rejected: + !AsyncResult.isFailure(sessionResult) && session?.authenticated === false, + }) + : "This connection does not have permission to manage the environment."} ) : null} {error ? ( diff --git a/apps/web/src/components/settings/SessionPermissions.tsx b/apps/web/src/components/settings/SessionPermissions.tsx index 1cbff3d750cf3..30f61ddd80371 100644 --- a/apps/web/src/components/settings/SessionPermissions.tsx +++ b/apps/web/src/components/settings/SessionPermissions.tsx @@ -1,5 +1,8 @@ import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; -import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; +import { + AUTH_SCOPE_OPTIONS, + sessionPermissionsUnavailableMessage, +} from "@t3tools/shared/authScopeOptions"; import { useEnvironmentSessionState } from "~/state/session"; export function SessionPermissions({ @@ -32,7 +35,11 @@ export function SessionPermissions({ ) : (

- Permissions not checked. Connect to this environment to view this session’s permissions. + {sessionPermissionsUnavailableMessage({ + connected, + failed: session.hasError, + rejected: !session.hasError && !session.isPending && session.data?.authenticated === false, + })}

); } diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts new file mode 100644 index 0000000000000..740ab2229bdb4 --- /dev/null +++ b/packages/client-runtime/src/state/session.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcHttp from "../rpc/http.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createEnvironmentSessionAtoms } from "./session.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://environment.example.test", + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: { _tag: "Bearer", token: "bearer-token" }, + target: TARGET, +}; +const SESSION = { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "t3_session", + }, + scopes: ["orchestration:read"], +} satisfies AuthSessionState; + +const rpcSession = (): RpcSession => ({ closed: Effect.never }) as unknown as RpcSession; + +const makeHarness = Effect.fn("TestSessionState.makeHarness")(function* ( + reply: (requestNumber: number) => Promise, +) { + let requestCount = 0; + const transport = yield* SubscriptionRef.make(Option.some(rpcSession())); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: transport, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + RpcHttp.layerRemoteHttpClient(async () => reply(++requestCount)), + // The test's clock, so the retry wait moves with `TestClock.adjust`. + Layer.succeed(Clock.Clock, yield* Clock.Clock), + ), + ); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const results = yield* Queue.unbounded>(); + const atom = createEnvironmentSessionAtoms(runtime).sessionStateAtom(TARGET.environmentId); + const unsubscribe = registry.subscribe(atom, (result) => Queue.offerUnsafe(results, result), { + immediate: true, + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const nextSettled = Effect.fn("TestSessionState.nextSettled")(function* () { + for (;;) { + const result = yield* Queue.take(results); + if (!result.waiting && result._tag !== "Initial") return result; + } + }); + return { transport, nextSettled }; +}); + +const unreachable = () => Promise.reject(new TypeError("Network request failed")); + +describe("environment session state", () => { + it.effect("fetches permissions again when the connection is re-established", () => + Effect.gen(function* () { + const harness = yield* makeHarness((requestNumber) => + requestNumber === 1 ? unreachable() : Promise.resolve(Response.json(SESSION)), + ); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession())); + + expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION }); + }), + ); + + it.effect("retries a failed permission check with backoff that restarts per connection", () => + Effect.gen(function* () { + const harness = yield* makeHarness((requestNumber) => + requestNumber <= 3 ? unreachable() : Promise.resolve(Response.json(SESSION)), + ); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* TestClock.adjust("2 seconds"); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession())); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* TestClock.adjust("2 seconds"); + expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION }); + }), + ); +}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index efdf0b37c0a09..90afa29f9d05d 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -14,6 +14,7 @@ import type { PreparedConnection } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./environmentStreams.ts"; @@ -39,6 +40,8 @@ function initialConfigOption( const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; const MAX_SESSION_STATE_TIMEOUT_MS = 60_000; +const SESSION_STATE_RETRY_BASE_MS = 2_000; +const SESSION_STATE_RETRY_MAX_MS = 60_000; export const fetchEnvironmentSessionState = Effect.fn( "clientRuntime.state.fetchEnvironmentSessionState", @@ -136,13 +139,38 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); - const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => - runtime + const transportSessionAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.session)), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => { + let consecutiveFailures = 0; + let backoffConnection: RpcSession | null = null; + return runtime .atom((get) => { const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); - if (prepared === null) { + // Each established connection fetches anew, and a failure retries while it stays up, + // so a fetch that failed mid-reconnect cannot outlive the socket that recovered. + const connection = AsyncResult.getOrElse(get(transportSessionAtom(environmentId)), () => + Option.none(), + ); + if (prepared === null || Option.isNone(connection)) { return Effect.never; } + if (connection.value !== backoffConnection) { + backoffConnection = connection.value; + consecutiveFailures = 0; + } return Effect.gen(function* () { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); const remoteAuthorization = yield* Effect.serviceOption( @@ -155,14 +183,31 @@ function makeEnvironmentSessionAtoms( signer, remoteAuthorization, }).pipe(Effect.provideService(HttpClient.HttpClient, client.value)); - }); + }).pipe( + Effect.tap(() => + Effect.sync(() => { + consecutiveFailures = 0; + }), + ), + Effect.tapError(() => { + const delayMs = Math.min( + SESSION_STATE_RETRY_BASE_MS * 2 ** consecutiveFailures, + SESSION_STATE_RETRY_MAX_MS, + ); + consecutiveFailures += 1; + return Effect.sleep(delayMs).pipe( + Effect.andThen(Effect.sync(() => get.refreshSelf())), + Effect.forkScoped, + ); + }), + ); }) .pipe( Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`environment-session-state:${environmentId}`), - ), - ); + ); + }); const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make( diff --git a/packages/shared/src/authScopeOptions.ts b/packages/shared/src/authScopeOptions.ts index d443e50326d28..9a2261a09f3e9 100644 --- a/packages/shared/src/authScopeOptions.ts +++ b/packages/shared/src/authScopeOptions.ts @@ -104,3 +104,19 @@ export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{ description: "Change managed tunnel connectivity.", }, ]; + +/** Shown instead of the granted scopes while they are unknown, so every surface says the same thing. */ +export function sessionPermissionsUnavailableMessage(input: { + readonly connected: boolean; + /** The check errored; the client retries it while connected. */ + readonly failed: boolean; + /** The environment answered that this session is not signed in; retrying will not change that. */ + readonly rejected: boolean; +}): string { + if (!input.connected) return "Connect to this environment to view this session’s permissions."; + if (input.rejected) + return "This environment did not accept your session. Reconnect to try again."; + return input.failed + ? "Could not verify your permissions. Retrying automatically." + : "Checking permissions…"; +} From da04ebc5087477a82deae9ce19c03539a627359f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:06:51 -0700 Subject: [PATCH 02/21] fix(mobile): tab bar spans the screen on iOS before 26 (#27089) Co-authored-by: Claude Opus 5.5 --- .../workspace/WorkspaceNativeTabs.ios.tsx | 16 ++++++++++------ .../features/workspace/WorkspaceToolbar.ios.tsx | 10 +++++----- .../workspace/useSystemWorkspaceTabBar.ts | 13 +++++++++++++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx index 929660587d731..e93175677fc02 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx @@ -18,7 +18,11 @@ import { NativeWorkspaceTabActivityContext, WorkspaceSlotOptionsContext, } from "../../native/v5-workspace-context"; -import { useCompactWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; +import { + useCompactWorkspaceTabs, + useFloatingWorkspaceTabs, + useSystemWorkspaceTabBar, +} from "./useSystemWorkspaceTabBar"; import { useControlTheme } from "../../components/ui/useControlTheme"; import { useDeviceType } from "../../native/native-layout-metrics"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; @@ -80,7 +84,7 @@ export function WorkspaceNativeTabs(props: { const deviceType = useDeviceType(); const compactTabs = useCompactWorkspaceTabs(); const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); - const compactSidebarTabs = deviceType === "phone" || !layout.usesSplitView; + const floatingTabs = useFloatingWorkspaceTabs(); const { width } = useWindowDimensions(); const { colors } = useControlTheme(); const selectedTab = workspace.state.selectedTab; @@ -158,17 +162,17 @@ export function WorkspaceNativeTabs(props: { bottom: 0, // UIKit can shrink the sidebar to 280pt independently of its preferred width. width: - deviceType === "pad" + deviceType === "pad" && (floatingTabs || layout.usesSplitView) ? Math.min(280, layout.listPaneWidth ?? width, width) : (layout.listPaneWidth ?? width), height: 128, }} > (null); // The Settings list takes the selected tab's place, so search follows it. const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab; @@ -189,9 +189,9 @@ export function WorkspaceToolbar(props: { {composeButton} {selectedTab !== "threads" || isDuoDevice(deviceType) || - deviceType === "pad" || + (deviceType === "pad" && layout.usesSplitView) || sidebarTabs === "picker" || - compactTabs ? null : ( + floatingTabs ? null : ( Date: Thu, 29 Oct 2026 13:17:45 -0700 Subject: [PATCH 03/21] perf(server): thread recovery reads are 10x faster (#27099) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ProjectionStore.test.ts | 15 ++++++++++++++- apps/server/src/persistence/Migrations.ts | 2 ++ .../Migrations/073_ActiveTurnItemIndexes.ts | 19 +++++++++++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 ++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 17b8bb818864a..8d46b51031506 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -6640,10 +6640,23 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: releasedAt, }, }); + const { statements, tracer } = traceSqlStatements(); assert.deepEqual( - (yield* store.getRuntimeRecoveryProjection(threadId)).runs.map((run) => run.id), + (yield* store + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.withTracer(tracer))).runs.map((run) => run.id), [runId, newerRunId], ); + // Live-item filters must hit the active partial indexes, not walk the thread's history. + const sql = yield* SqlClient.SqlClient; + for (const statement of statements.filter((query) => query.includes("turn_items"))) { + const plan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statement}`, + )).map((row) => row.detail); + for (const detail of plan) { + assert.notMatch(detail, /turn_items_(thread_ordinal|thread_run)_idx \(thread_id=\?\)$/); + } + } }), ); }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cc395406eb502..8143a86b15034 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -75,6 +75,7 @@ import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; +import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -150,6 +151,7 @@ const migrationEntries = [ [70, "PeerLinks", Migration0070], [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], + [73, "ActiveTurnItemIndexes", Migration0073], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts new file mode 100644 index 0000000000000..d9759bca6d82a --- /dev/null +++ b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Recovery and provider-thread reads filter live items by this exact predicate; without + // these, each read walked every historical item in the thread. + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_ordinal_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) + WHERE status IN ('pending', 'running', 'waiting') + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_run_idx + ON orchestration_v2_projection_turn_items(thread_id, run_id) + WHERE status IN ('pending', 'running', 'waiting') + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 91e40578091a7..c08062c09d1ad 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -52,6 +52,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -147,6 +148,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 3898c3298617e8a12177dbc8cd1ad681c96e3fac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:17:50 -0700 Subject: [PATCH 04/21] perf(server): source control discovery no longer stalls up to 10s on Bitbucket (#27102) Co-authored-by: Claude Opus 5.5 --- .../SourceControlProviderRegistry.test.ts | 32 +++++++++- .../src/server/BitbucketApi.test.ts | 3 +- .../src/server/BitbucketApi.ts | 31 ++++++---- .../src/server/discovery.ts | 60 ++++++++++--------- 4 files changed, 85 insertions(+), 41 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 249bc07b3ad3f..05b6a1a8d0e42 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -118,7 +118,12 @@ function makeRegistry(input: { kind: provider.kind, label: provider.kind, installHint: "unused", - probeAuth: Effect.die("unused"), + probeAuth: Effect.succeed({ + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), }, })), ).pipe( @@ -440,3 +445,28 @@ it.effect("asks again once Settings change", () => assert.strictEqual((yield* resolveUnknown(registry, "c")).provider.kind, "github"); }), ); + +it.effect("answers discovery without waiting on a hosting API that stalls", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + discovery: [ + claimingCli("gitlab"), + { + type: "api", + kind: kind("bitbucket"), + label: "Bitbucket", + installHint: "unused", + probeAuth: Effect.never, + }, + ], + }); + const discovering = yield* registry.discover.pipe(Effect.forkChild); + yield* TestClock.adjust("3 seconds"); + const items = yield* Fiber.join(discovering); + const bitbucket = items.find((item) => item.kind === "bitbucket"); + assert.strictEqual(bitbucket?.status, "available"); + assert.strictEqual(bitbucket?.auth.status, "unknown"); + assert.strictEqual(items.find((item) => item.kind === "gitlab")?.status, "available"); + }), +); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index 26891c3548974..c0464276abf24 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -513,7 +513,7 @@ it.effect("never puts a saved token that is unsafe for an HTTP header on the wir }); it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { - const { layer } = makeLayer({ + const { execute, layer } = makeLayer({ response: () => new Response(null, { status: 401 }), env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, }); @@ -523,6 +523,7 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + assert.strictEqual(execute.mock.calls.length, 0); yield* settings.update({ sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } }, diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 72077040aa870..863b206d95578 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -986,18 +986,25 @@ export const make = Effect.gen(function* () { return BitbucketApi.of({ request, - probeAuth: executeJson( - "probeAuth", - HttpClientRequest.get(apiUrl("/user")), - BitbucketUserSchema, - ).pipe( - Effect.map((user) => ({ - status: "authenticated" as const, - account: nonEmpty(user.username ?? user.display_name ?? user.account_id), - host: Option.some("bitbucket.org"), - detail: Option.none(), - })), - Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), + // Bitbucket answers anonymous /user with 401, so without a credential there is nothing to ask. + probeAuth: currentCredential.pipe( + Effect.flatMap((credential) => + credential === null + ? Effect.succeed(authFromCredential(null)) + : executeJson( + "probeAuth", + HttpClientRequest.get(apiUrl("/user")), + BitbucketUserSchema, + ).pipe( + Effect.map((user) => ({ + status: "authenticated" as const, + account: nonEmpty(user.username ?? user.display_name ?? user.account_id), + host: Option.some("bitbucket.org"), + detail: Option.none(), + })), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), + ), + ), ), listPullRequests: Effect.fn("BitbucketApi.listPullRequests")(function (input) { return resolveRepository(input).pipe( diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts index 66889ddc4f2ab..2f018bfa10ca3 100644 --- a/packages/source-control-core/src/server/discovery.ts +++ b/packages/source-control-core/src/server/discovery.ts @@ -75,6 +75,8 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { }; const DEFAULT_PROBE_TIMEOUT_MS = 5_000; +/** An API host that stalls must not hold the whole discovery answer behind it. */ +const API_PROBE_TIMEOUT = "3 seconds"; function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; @@ -236,6 +238,12 @@ export function probeSourceControlProvider(input: { if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd); if (input.spec.type === "api") { return input.spec.probeAuth.pipe( + Effect.timeoutOption(API_PROBE_TIMEOUT), + Effect.map( + Option.getOrElse(() => + unknownAuth("The hosting service did not answer in time. Refresh to try again."), + ), + ), Effect.map( (auth) => ({ @@ -253,20 +261,15 @@ export function probeSourceControlProvider(input: { const spec = input.spec; - return probeCli({ - spec, - process: input.process, - cwd: input.cwd, - }).pipe( - Effect.flatMap((item) => { - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("Hosting integration command was not found on the server PATH."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return input.process + // The auth check does not depend on the version output, so both spawn at once. + return Effect.all( + [ + probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }), + input.process .run({ operation: "source-control.discovery.auth", command: spec.executable, @@ -278,21 +281,24 @@ export function probeSourceControlProvider(input: { appendTruncationMarker: true, }) .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), + Effect.map(spec.parseAuth), Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), + Effect.succeed(unknownAuth(Option.getOrUndefined(detailFromCause(cause)))), ), - ); - }), + ), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map( + ([item, auth]) => + ({ + ...item, + auth: + item.status === "available" + ? auth + : unknownAuth("Hosting integration command was not found on the server PATH."), + }) satisfies SourceControlProviderDiscoveryItem, + ), ); } From 7f39a860d9775e1c5e7cf9c0655d719d966f64f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:17:55 -0700 Subject: [PATCH 05/21] perf: huge checkpoints ship up to 75x smaller to clients (#27110) Co-authored-by: Claude Opus 5.5 --- .../mobile/src/features/review/reviewModel.ts | 7 ++- .../src/orchestration-v2/ThreadStream.ts | 10 ++-- .../orchestration-v2/WireProjection.test.ts | 43 ++++++++++++++ .../src/orchestration-v2/WireProjection.ts | 58 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 10 ++-- .../src/components/chat/ChangedFilesTree.tsx | 34 +++++++---- .../components/chat/TimelineAssistantRows.tsx | 2 + apps/web/src/lib/turnDiffTree.ts | 14 ----- .../src/state/threadCheckpoints.ts | 17 ++++++ packages/contracts/src/orchestrationV2.ts | 10 ++++ 10 files changed, 167 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index bc0e23b336582..1b2131c73e0c1 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,6 +1,9 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; -import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { + checkpointFileTotals, + type ThreadCheckpointSummary, +} from "@t3tools/client-runtime/state/thread-checkpoints"; import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; @@ -107,7 +110,7 @@ function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { } function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { - const fileCount = checkpoint.files.length; + const fileCount = checkpointFileTotals(checkpoint).count; if (checkpoint.status !== "ready") { return `Diff ${checkpoint.status}`; } diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 3c4baab80ef07..80ee5c32d6152 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -10,7 +10,10 @@ import { buildBoundedThreadProjection, type BoundedProjectionResult, } from "./threadHistoryPaging.ts"; -import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { + capProjectionCheckpointFilesForWire, + projectThreadProjectionForWire, +} from "./WireProjection.ts"; export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; @@ -44,10 +47,9 @@ export function boundedSnapshotResponseFields(input: { readonly compactIds: boolean; }) { const { bounded } = input; + const capped = capProjectionCheckpointFilesForWire(bounded.projection); // Dedupe keeps shared items one object, which the compaction's identity check needs. - const projection = input.dedupeBodies - ? dedupeSnapshotBodies(bounded.projection) - : bounded.projection; + const projection = input.dedupeBodies ? dedupeSnapshotBodies(capped) : capped; const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(projection) : null; return { projection: compactProjection ?? projection, diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index d449aeb933dbf..0d63d832f0ca9 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -1,4 +1,7 @@ import { + CheckpointId, + CheckpointRef, + CheckpointScopeId, MessageId, ContextHandoffId, ProviderThreadId, @@ -381,6 +384,46 @@ describe("live body deduper", () => { ]); }); + it("caps a large checkpoint's files for the wire and still dedupes its item", () => { + const files = Array.from({ length: 250 }, (_, index) => ({ + path: `vendor/file-${index}.ts`, + kind: "modified", + additions: 2, + deletions: 1, + })); + const checkpointId = CheckpointId.make("checkpoint-large"); + const scopeId = CheckpointScopeId.make("scope-large"); + const captured = event(1, { + type: "checkpoint.captured", + payload: { + id: checkpointId, + threadId: base.threadId, + scopeId, + runId: null, + nodeId: NodeId.make("node-large"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: null, + ref: CheckpointRef.make("refs/t3/checkpoints/large"), + status: "ready", + files, + capturedAt: at, + }, + }); + const item = event(2, { + type: "turn-item.updated", + payload: { ...base, type: "checkpoint", checkpointId, scopeId, files }, + }); + const dedupe = makeLiveBodyDeduper(); + const [record, card] = [captured, item].map((streamed) => + dedupe({ ...streamed, event: projectDomainEventForWire(streamed.event) }), + ); + + const filesTotal = { count: 250, additions: 500, deletions: 250 }; + expect(payloadOf(record!)).toMatchObject({ files: files.slice(0, 200), filesTotal }); + expect(payloadOf(card!)).toMatchObject({ files: [], filesOmitted: true, filesTotal }); + }); + it("ships a reply whole when its message arrives after it", () => { const first = reply(1, "a"); expect([first, message(2, "a")].map(makeLiveBodyDeduper())[0]).toBe(first); diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 6b66f1b91f204..2181b7feec5fa 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -21,6 +21,8 @@ import { toolOutputIndicatesFailure, } from "@t3tools/shared/toolOutput"; +// A checkpoint over vendored or generated trees can list 15k+ files; the card needs a prefix. +const MAX_WIRE_CHECKPOINT_FILES = 200; const MAX_DETAIL_STRING_BYTES = 32_768; const MAX_DYNAMIC_VALUE_BYTES = 16_384; const MAX_ON_DEMAND_BYTES = 256 * 1024; @@ -96,8 +98,59 @@ function summarizeDynamicValue(value: unknown): unknown { }; } +type CheckpointFiles = Pick; + +// Same input, same output object: snapshot compaction relies on shared item identity. +const cappedCheckpointFiles = new WeakMap(); + +function capCheckpointFiles(value: T): T { + if (value.files.length <= MAX_WIRE_CHECKPOINT_FILES || value.filesTotal !== undefined) { + return value; + } + const cached = cappedCheckpointFiles.get(value); + if (cached !== undefined) return cached as T; + let additions = 0; + let deletions = 0; + for (const file of value.files) { + additions += file.additions; + deletions += file.deletions; + } + const capped = { + ...value, + files: value.files.slice(0, MAX_WIRE_CHECKPOINT_FILES), + filesTotal: { count: value.files.length, additions, deletions }, + }; + cappedCheckpointFiles.set(value, capped); + return capped; +} + +/** Idempotent; bounded snapshots that skip `projectThreadProjectionForWire` still need it. */ +export function capProjectionCheckpointFilesForWire( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const checkpoints = projection.checkpoints.map(capCheckpointFiles); + const turnItems = projection.turnItems.map(capCheckpointItemFiles); + const visibleTurnItems = projection.visibleTurnItems.map((row) => { + const item = capCheckpointItemFiles(row.item); + return item === row.item ? row : { ...row, item }; + }); + const unchanged = (next: ReadonlyArray, previous: ReadonlyArray) => + next.every((value, index) => value === previous[index]); + return unchanged(checkpoints, projection.checkpoints) && + unchanged(turnItems, projection.turnItems) && + unchanged(visibleTurnItems, projection.visibleTurnItems) + ? projection + : { ...projection, checkpoints, turnItems, visibleTurnItems }; +} + +function capCheckpointItemFiles(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + return item.type === "checkpoint" ? capCheckpointFiles(item) : item; +} + export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { switch (item.type) { + case "checkpoint": + return capCheckpointFiles(item); case "handoff": { const { summary: _summary, ...projected } = item; return projected; @@ -241,6 +294,7 @@ export function projectThreadProjectionForWire( }; return { ...projection, + checkpoints: projection.checkpoints.map(capCheckpointFiles), contextHandoffs: projection.contextHandoffs.map(projectContextHandoffForWire), turnItems: projection.turnItems.map(project), visibleTurnItems: projection.visibleTurnItems.map((row) => ({ @@ -257,7 +311,9 @@ export function projectDomainEventForWire( ? { ...event, payload: projectTurnItemForWire(event.payload) } : event.type === "context-handoff.updated" ? { ...event, payload: projectContextHandoffForWire(event.payload) } - : event; + : event.type === "checkpoint.captured" + ? { ...event, payload: capCheckpointFiles(event.payload) } + : event; } const LIVE_DEDUPE_MEMORY = 32; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c7c0f77bc97fb..bef8f7ab405f1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,6 +3,7 @@ import { deriveLatestThreadRun, deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -218,7 +219,7 @@ export function shouldOpenProactiveTurnDiff(input: { } export function resolveProactiveTurnDiffAction(input: { - checkpoint: Pick | undefined; + checkpoint: Pick | undefined; isGitRepo: boolean | undefined; }): "defer" | "ignore" | "open" { if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; @@ -230,11 +231,8 @@ export function resolveProactiveTurnDiffAction(input: { ) { return "ignore"; } - const changedLines = input.checkpoint.files.reduce( - (total, file) => total + file.additions + file.deletions, - 0, - ); - return input.checkpoint.files.length >= 3 || changedLines >= 50 ? "open" : "ignore"; + const totals = checkpointFileTotals(input.checkpoint); + return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } /** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 0b569cbc0c563..868484f37b474 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,11 +1,7 @@ -import { type RunId } from "@t3tools/contracts"; +import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts"; import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; -import { - buildTurnDiffTree, - summarizeTurnDiffStats, - type TurnDiffTreeNode, -} from "../../lib/turnDiffTree"; +import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { ChevronRightIcon, FileDiffIcon } from "lucide-react"; import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide"; import { cn } from "~/lib/utils"; @@ -30,7 +26,9 @@ type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => vo export const ChangedFilesCard = memo(function ChangedFilesCard(props: { runId: RunId; + /** May be a prefix of the changed files; `totals` covers all of them. */ files: ReadonlyArray; + totals: OrchestrationV2CheckpointFileTotals; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; onToggleAllDirectories: () => void; @@ -40,13 +38,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const { runId, files, + totals, allDirectoriesExpanded, resolvedTheme, onToggleAllDirectories, onOpenTurnDiff, onFileContextMenu, } = props; - const summaryStat = summarizeTurnDiffStats(files); + const hiddenFileCount = totals.count - files.length; const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); return ( @@ -60,12 +59,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { >
- {files.length} changed file{files.length === 1 ? "" : "s"} + {totals.count.toLocaleString()} changed file{totals.count === 1 ? "" : "s"} - {hasNonZeroStat(summaryStat) && ( + {hasNonZeroStat(totals) && ( @@ -126,6 +125,19 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} onFileContextMenu={onFileContextMenu} /> + {hiddenFileCount > 0 && ( +
+ +
+ )}
); }); diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 89b52ae5c5cba..228eb6f79b9c3 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -9,6 +9,7 @@ import { Button } from "../ui/button"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; import { McpAppFrame } from "./McpAppFrame"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; @@ -347,6 +348,7 @@ function AssistantChangedFilesSectionInner({ diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index 810a04755e895..e841e18ce24d2 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -98,20 +98,6 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { return [...subdirectories, ...files]; } -export function summarizeTurnDiffStats(files: ReadonlyArray): TurnDiffStat { - return files.reduce( - (acc, file) => { - const stat = readStat(file); - if (!stat) return acc; - return { - additions: acc.additions + stat.additions, - deletions: acc.deletions + stat.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); -} - export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { const root: MutableDirectoryNode = { name: "", diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 4501007e20053..ee2fe45d9c756 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -3,6 +3,7 @@ import type { CheckpointRef, CheckpointScopeId, MessageId, + OrchestrationV2CheckpointFileTotals, OrchestrationV2ThreadProjection, RunId, } from "@t3tools/contracts"; @@ -15,12 +16,14 @@ export interface ThreadCheckpointSummary { readonly checkpointTurnCount: number; readonly checkpointRef: CheckpointRef; readonly status: "ready" | "missing" | "error" | "stale"; + /** A prefix of the changed files when `filesTotal` is set; see `checkpointFileTotals`. */ readonly files: ReadonlyArray<{ readonly path: string; readonly kind: string; readonly additions: number; readonly deletions: number; }>; + readonly filesTotal?: OrchestrationV2CheckpointFileTotals; readonly assistantMessageId: MessageId | null; readonly completedAt: string; } @@ -43,9 +46,23 @@ export function deriveThreadCheckpointSummaries( checkpointRef: checkpoint.ref, status: checkpoint.status, files: checkpoint.files, + ...(checkpoint.filesTotal === undefined ? {} : { filesTotal: checkpoint.filesTotal }), assistantMessageId, completedAt: DateTime.formatIso(checkpoint.capturedAt), }, ]; }); } + +export function checkpointFileTotals( + checkpoint: Pick, +): OrchestrationV2CheckpointFileTotals { + if (checkpoint.filesTotal !== undefined) return checkpoint.filesTotal; + let additions = 0; + let deletions = 0; + for (const file of checkpoint.files) { + additions += file.additions; + deletions += file.deletions; + } + return { count: checkpoint.files.length, additions, deletions }; +} diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 747d05e7ce4eb..026e74831233d 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1349,6 +1349,14 @@ export const OrchestrationV2CheckpointFileSummary = Schema.Struct({ }); export type OrchestrationV2CheckpointFileSummary = typeof OrchestrationV2CheckpointFileSummary.Type; +/** Sent only when the wire carries a prefix of `files`; the totals cover the full list. */ +export const OrchestrationV2CheckpointFileTotals = Schema.Struct({ + count: NonNegativeInt, + additions: NonNegativeInt, + deletions: NonNegativeInt, +}); +export type OrchestrationV2CheckpointFileTotals = typeof OrchestrationV2CheckpointFileTotals.Type; + export const OrchestrationV2Checkpoint = Schema.Struct({ id: CheckpointId, threadId: ThreadId, @@ -1361,6 +1369,7 @@ export const OrchestrationV2Checkpoint = Schema.Struct({ ref: CheckpointRef, status: Schema.Literals(["ready", "missing", "error", "stale"]), files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), capturedAt: Schema.DateTimeUtc, }); export type OrchestrationV2Checkpoint = typeof OrchestrationV2Checkpoint.Type; @@ -1659,6 +1668,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ checkpointId: CheckpointId, scopeId: CheckpointScopeId, files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), /** Same as the files of checkpoint `checkpointId`. */ filesOmitted: DedupedBodyMarker, }), From 8bf4822f5eb5b24f3129960ce771daacc5c01a44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:18:37 -0700 Subject: [PATCH 06/21] perf(server): sidebar shell loads 3x faster and startup thread counts 80x faster (#27106) Co-authored-by: Claude Opus 5.5 --- .../transferBudgetV2.integration.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 12 + .../orchestration-v2/ProjectionStore.test.ts | 34 +++ .../src/orchestration-v2/ProjectionStore.ts | 110 +++++++ .../ProviderTurnControlService.test.ts | 2 + .../src/orchestration-v2/ShellStream.ts | 287 +++++++++++++----- .../orchestration-v2/ThreadLaunchService.ts | 7 +- .../ThreadManagementService.ts | 13 +- apps/server/src/orchestration-v2/http.ts | 31 +- apps/server/src/project/ProjectService.ts | 6 +- .../src/relay/AgentAwarenessRelay.test.ts | 2 + apps/server/src/relay/AgentAwarenessRelay.ts | 13 +- .../src/serverRuntimeStartup.autoPull.test.ts | 8 +- apps/server/src/serverRuntimeStartup.test.ts | 9 +- apps/server/src/serverRuntimeStartup.ts | 9 +- apps/server/src/ws.test.ts | 15 +- apps/server/src/ws.ts | 31 +- 17 files changed, 436 insertions(+), 154 deletions(-) diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 22db0e643db9b..68a622c541fe9 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -117,6 +117,7 @@ const layerManagement = Layer.unwrap( .pipe(Effect.orDie), getThreadShell: (id) => projections.getThreadShell(id).pipe(Effect.orDie), getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie), + listActiveShellIndex: () => projections.listActiveShellIndex().pipe(Effect.orDie), listActiveSubagentThreadIds: () => projections.listActiveSubagentThreadIds().pipe(Effect.orDie), readShellSnapshot: (options) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 76bcc00bbcb4e..c464a8743f3c2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -141,6 +141,7 @@ import { type ProjectionRecords, type ProjectionCheckpointContext, type OlderSettledThreadsListing, + type ActiveShellIndexEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -392,6 +393,11 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly countThreads: () => Effect.Effect; + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -13434,6 +13440,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError(shellProjectionError)); }, ), + countThreads: Effect.fn("OrchestratorV2.countThreads")(function () { + return projectionStore.countThreads().pipe(Effect.mapError(shellProjectionError)); + }), + listActiveShellIndex: Effect.fn("OrchestratorV2.listActiveShellIndex")(function () { + return projectionStore.listActiveShellIndex().pipe(Effect.mapError(shellProjectionError)); + }), listOlderSettledThreads: Effect.fn("OrchestratorV2.listOlderSettledThreads")(function (query) { return projectionStore .listOlderSettledThreads(query) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 8d46b51031506..1dc7c912e7765 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -26,6 +26,7 @@ import { ThreadId, TurnItemId, type ThreadPullRequestLink, + isLiveSubagentShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -76,6 +77,33 @@ const traceSqlStatements = () => { return { statements, tracer }; }; +/** The shell index flags every live shell and matches its shells on the fields subscriber trims read. */ +const assertShellIndexCoversShells = Effect.fnUntraced(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const shells = (yield* store.getShellSnapshot({ location: "active" })).threads; + const index = yield* store.listActiveShellIndex(); + const iso = (value: DateTime.Utc | null | undefined) => + value == null ? null : DateTime.formatIso(value); + const trimmed = ( + row: Pick< + ProjectionStore.ActiveShellIndexEntry, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" + >, + ) => ({ + id: row.id, + projectId: row.projectId, + lineage: row.lineage, + settledOverride: row.settledOverride, + settledAt: iso(row.settledAt), + pinnedAt: iso(row.pinnedAt), + snoozedUntil: iso(row.snoozedUntil), + }); + assert.deepEqual(index.map(trimmed), shells.map(trimmed)); + shells.forEach((shell, position) => { + if (isLiveSubagentShell(shell)) assert.isTrue(index[position]?.mayBeLive, shell.id); + }); +}); + const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* ( suffix: string, ) { @@ -272,6 +300,7 @@ it.effect("SQL shells flag only an active turn blocked solely on T3 thread waits assert.equal(shell?.status, "running"); assert.equal(shell?.blockedOnThreadWait ?? false, expected); } + yield* assertShellIndexCoversShells(); }); const wait = { ...itemBase, @@ -531,6 +560,7 @@ it.effect("SQL shells use the latest subagent owner for duplicate provider-threa const listed = (yield* store.getShellSnapshot()).threads.find( (thread) => thread.id === targetThreadId, ); + yield* assertShellIndexCoversShells(); for (const shell of [yield* store.getThreadShell(targetThreadId), listed]) { assert.equal(shell?.providerInstanceId, otherInstanceId); assert.equal(shell?.activeRunId, null); @@ -1336,6 +1366,7 @@ it.effect("SQL list older settled threads below a cursor", () => const next = yield* list({ settledAt: DateTime.formatIso(at(4)), threadId: id("b") }, 10); assert.deepEqual(next.threadIds, [id("c"), id("d"), id("snoozed")]); assert.deepEqual(yield* list(before, 25, []), { threadIds: [], counts: [] }); + yield* assertShellIndexCoversShells(); const scoped = yield* list(before, 0, [ProjectId.make("project:b")]); assert.deepEqual(scoped, { threadIds: [], @@ -3798,6 +3829,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( (row) => row.id === threadId, )!; + yield* assertShellIndexCoversShells(); for (const questionShell of [ sqlShell, ProjectionStore.threadShellFromProjection(projection), @@ -4860,6 +4892,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET status = 'running', payload_json = json_set(payload_json, '$.status', 'running') WHERE run_id = ${runId} `; + yield* assertShellIndexCoversShells(); const secretOnlyShell = (yield* projectionStore.getShellSnapshot()).threads.find( (thread) => thread.id === threadId, ); @@ -6466,6 +6499,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (candidate) => candidate.id === threadId, ); assert.deepEqual(listed?.pendingBackgroundTasks, shell?.pendingBackgroundTasks); + yield* assertShellIndexCoversShells(); return shell && projectThreadAwarenessV2({ environmentId, project, thread: shell })?.phase; }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 287a30a9d904e..6bb04041c75b6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -393,6 +393,15 @@ export interface ShellSnapshotOptions { readonly threadIds?: ReadonlyArray; } +/** What a shell subscriber's trims read from an active thread, without building its shell. */ +export interface ActiveShellIndexEntry extends Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" +> { + /** False only when no run, request, work item, or watch can make the shell live. */ + readonly mayBeLive: boolean; +} + export interface OlderSettledThreadsQuery { readonly before: OrchestrationV2OlderSettledCursor; readonly projectIds?: ReadonlyArray | undefined; @@ -450,6 +459,13 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Threads that are not deleted, archived ones included. */ + readonly countThreads: () => Effect.Effect; + /** Every active thread, in shell order. */ + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, @@ -6614,6 +6630,98 @@ export const layer: Layer.Layer = Effect.mapError(shellSnapshotReadError), ); + const countThreads = Effect.fn("ProjectionStore.countThreads")(function* () { + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL + `; + return rows[0]?.count ?? 0; + }, Effect.mapError(shellSnapshotReadError)); + + const listActiveShellIndex = Effect.fn("ProjectionStore.listActiveShellIndex")(function* () { + const rows = yield* sql<{ + readonly thread_id: string; + readonly project_id: string; + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly settled_override: string | null; + readonly settled_at: string | null; + readonly pinned_at: string | null; + readonly snoozed_until: string | null; + readonly may_be_live: number; + }>` + SELECT + t.thread_id, + t.project_id, + json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, + json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, + json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.settledOverride') AS settled_override, + json_extract(t.payload_json, '$.settledAt') AS settled_at, + json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, + json_extract(t.payload_json, '$.snoozedUntil') AS snoozed_until, + -- A superset of isLiveSubagentShell: the sources of its run status, pending request, + -- and pending background work. + ( + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs r + WHERE r.thread_id = t.thread_id + AND r.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runtime_requests request + WHERE request.thread_id = t.thread_id AND request.status = 'pending' + ) + -- Same predicates as the shell's pending item and link reads, for their partial indexes. + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items item + WHERE item.thread_id = t.thread_id + AND item.type IN ('command_execution', 'dynamic_tool', 'subagent') + AND item.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items link_request + WHERE link_request.thread_id = t.thread_id + AND link_request.type = 'link_request' AND link_request.status = 'waiting' + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_provider_threads provider_thread + WHERE provider_thread.thread_id = t.thread_id + AND json_array_length(provider_thread.payload_json, '$.pendingBackgroundTasks') > 0 + ) + OR EXISTS ( + SELECT 1 FROM json_each(t.payload_json, '$.pullRequests') link + WHERE json_type(link.value, '$.watch') IS NOT NULL + ) + ) AS may_be_live + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.archivedAt') IS NULL + ORDER BY t.updated_at ASC, t.thread_id ASC + `; + return rows.map((row): ActiveShellIndexEntry => ({ + id: ThreadId.make(row.thread_id), + projectId: ProjectId.make(row.project_id), + lineage: { + parentThreadId: + row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), + relationshipToParent: + row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" + ? row.relationship_to_parent + : null, + rootThreadId: ThreadId.make(row.root_thread_id), + }, + settledOverride: + row.settled_override === "settled" || row.settled_override === "active" + ? row.settled_override + : null, + settledAt: row.settled_at === null ? null : DateTime.makeUnsafe(row.settled_at), + pinnedAt: row.pinned_at === null ? null : DateTime.makeUnsafe(row.pinned_at), + snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), + mayBeLive: row.may_be_live === 1, + })); + }, Effect.mapError(shellSnapshotReadError)); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( shareId: GuestShareId, ) { @@ -6768,6 +6876,8 @@ export const layer: Layer.Layer = apply, getShellSnapshot: readOnReader(getShellSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), + countThreads: readOnReader(countThreads), + listActiveShellIndex: readOnReader(listActiveShellIndex), readShellSnapshot: readOnReader(readShellSnapshot), listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index a39f6b7f036a6..1d79e87eb9b91 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -213,6 +213,8 @@ it.effect( getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), + countThreads: () => Effect.die("unused countThreads"), + listActiveShellIndex: () => Effect.die("unused listActiveShellIndex"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 8a075e13c528b..67d07d6684c08 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -31,6 +31,7 @@ import type * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; import { bufferLatestLiveStream } from "./LiveStreamBudget.ts"; +import type { ActiveShellIndexEntry } from "./ProjectionStore.ts"; type ShellDelta = Extract; @@ -56,7 +57,7 @@ export const bufferShellLiveStream = ( limits, ); -export function buildActiveShellSnapshot(input: { +function buildActiveShellSnapshot(input: { readonly projects: ReadonlyArray; readonly threads: OrchestrationV2ThreadShellSnapshot; readonly snapshotSequence: number; @@ -70,26 +71,119 @@ export function buildActiveShellSnapshot(input: { }; } +type ShellTrimRow = Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" +>; + /** - * Idle subagent children have no sidebar row, so clients that opt in read them from their thread - * family instead. A child stays while it is live or the parent of another child, so the request - * and working walks over shell rows never meet a gap in a nested chain. + * What a subscriber's trims read across every active row. Computed from full shells or from the + * store's index, so a snapshot read only for the rows it sends trims the same way. */ -export function omitIdleSubagentShells( - threads: ReadonlyArray, -): ReadonlyArray { - const parentIds = new Set(); - for (const thread of threads) { - if (thread.lineage.relationshipToParent === "subagent" && thread.lineage.parentThreadId) { - parentIds.add(thread.lineage.parentThreadId); +interface ShellTrimFacts { + readonly subagentParentIds: ReadonlySet; + readonly subagentThreadIds: Set; + /** Undefined when no thread is settled, so none is older. */ + readonly olderSettledBeforeMs: number | undefined; + readonly olderSettledCounts: ReadonlyArray; +} + +const NEWEST_SETTLED_KEPT = 25; + +function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { + const subagentParentIds = new Set(); + const subagentThreadIds = new Set(); + const settledTimes: Array = []; + for (const row of rows) { + if (row.lineage.relationshipToParent === "subagent") { + subagentThreadIds.add(row.id); + if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); + } else if (row.settledOverride === "settled" && row.settledAt !== null) { + settledTimes.push(DateTime.toEpochMillis(row.settledAt)); + } + } + settledTimes.sort((left, right) => right - left); + // With fewer than 25, the oldest one is the cut, so nothing is older. + const olderSettledBeforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1); + const counts = new Map(); + if (olderSettledBeforeMs !== undefined) { + for (const row of rows) { + if (isOlderSettledThreadShell(row, olderSettledBeforeMs)) { + counts.set(row.projectId, (counts.get(row.projectId) ?? 0) + 1); + } } } + return { + subagentParentIds, + subagentThreadIds, + olderSettledBeforeMs, + olderSettledCounts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), + }; +} + +function subagentAncestorIds(threads: ReadonlyArray>) { + const ancestorIds = new Set(); + for (const thread of threads) { + if (thread.lineage.relationshipToParent !== "subagent") continue; + if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); + ancestorIds.add(thread.lineage.rootThreadId); + } + return ancestorIds; +} + +function omitIdleSubagentShellsWith( + threads: ReadonlyArray, + facts: Pick, +): ReadonlyArray { return threads.filter( (thread) => thread.lineage.relationshipToParent !== "subagent" || isLiveSubagentShell(thread) || - parentIds.has(thread.id), + facts.subagentParentIds.has(thread.id), + ); +} + +function omitOlderSettledShellsWith( + input: { + readonly threads: ReadonlyArray; + readonly snapshotSequence: number; + readonly now: DateTime.Utc; + }, + facts: Pick, +): { + readonly threads: ReadonlyArray; + readonly olderSettled: OrchestrationV2OlderSettledThreads; +} { + const nowMs = DateTime.toEpochMillis(input.now); + const beforeMs = facts.olderSettledBeforeMs ?? nowMs; + const ancestorIds = subagentAncestorIds(input.threads); + const threads = input.threads.filter( + (thread) => + !isOlderSettledThreadShell(thread, beforeMs) || + thread.pinnedAt != null || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || + isLiveSubagentShell(thread) || + ancestorIds.has(thread.id), ); + return { + threads, + olderSettled: { + before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), + asOfSequence: input.snapshotSequence, + counts: facts.olderSettledCounts, + }, + }; +} + +/** + * Idle subagent children have no sidebar row, so clients that opt in read them from their thread + * family instead. A child stays while it is live or the parent of another child, so the request + * and working walks over shell rows never meet a gap in a nested chain. + */ +export function omitIdleSubagentShells( + threads: ReadonlyArray, +): ReadonlyArray { + return omitIdleSubagentShellsWith(threads, shellTrimFacts(threads)); } /** @@ -103,18 +197,13 @@ export function activeShellRowsForSubscriber( readonly threads: ReadonlyArray; readonly subagentThreadIds: Set; } { + const facts = shellTrimFacts(threads); return { - threads: omitIdleSubagents ? omitIdleSubagentShells(threads) : threads, - subagentThreadIds: new Set( - threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ), + threads: omitIdleSubagents ? omitIdleSubagentShellsWith(threads, facts) : threads, + subagentThreadIds: facts.subagentThreadIds, }; } -const NEWEST_SETTLED_KEPT = 25; - /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while @@ -128,77 +217,107 @@ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; readonly olderSettled: OrchestrationV2OlderSettledThreads; } { - const nowMs = DateTime.toEpochMillis(input.now); - const settledTimes = input.threads - .flatMap((thread) => - thread.lineage.relationshipToParent !== "subagent" && - thread.settledOverride === "settled" && - thread.settledAt !== null - ? [DateTime.toEpochMillis(thread.settledAt)] + return omitOlderSettledShellsWith(input, shellTrimFacts(input.threads)); +} + +interface ShellSubscriberOptions { + readonly omitIdleSubagents: boolean; + readonly omitOlderSettled: boolean; +} + +function trimShellSnapshot( + base: OrchestrationV2ShellSnapshot, + facts: ShellTrimFacts, + options: ShellSubscriberOptions & { readonly now: DateTime.Utc }, +): OrchestrationV2ShellSnapshot { + const threads = options.omitIdleSubagents + ? omitIdleSubagentShellsWith(base.threads, facts) + : base.threads; + if (!options.omitOlderSettled) return { ...base, threads }; + const older = omitOlderSettledShellsWith( + { threads, snapshotSequence: base.snapshotSequence, now: options.now }, + facts, + ); + return { ...base, threads: older.threads, olderSettled: older.olderSettled }; +} + +/** + * The threads whose shells a trimmed snapshot can contain: a superset of what the trims keep, so + * trimming the shells read for these ids gives the snapshot trimming every shell would. + */ +function shellSubscriberThreadIds( + index: ReadonlyArray, + options: ShellSubscriberOptions, +): ReadonlyArray { + const facts = shellTrimFacts(index); + const subagents = index.filter( + (entry) => + entry.lineage.relationshipToParent === "subagent" && + (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), + ); + const ancestorIds = subagentAncestorIds(subagents); + const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); + const beforeMs = facts.olderSettledBeforeMs; + return index.flatMap((entry) => + entry.lineage.relationshipToParent === "subagent" + ? keptSubagentIds.has(entry.id) + ? [entry.id] + : [] + : !options.omitOlderSettled || + beforeMs === undefined || + !isOlderSettledThreadShell(entry, beforeMs) || + entry.pinnedAt != null || + entry.snoozedUntil != null || + entry.mayBeLive || + ancestorIds.has(entry.id) + ? [entry.id] : [], - ) - .sort((left, right) => right - left); - // With fewer than 25, the oldest one is the cut, so nothing is older. - const beforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1) ?? nowMs; - const ancestorIds = new Set(); - for (const thread of input.threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; - if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); - ancestorIds.add(thread.lineage.rootThreadId); - } - const counts = new Map(); - const threads = input.threads.filter((thread) => { - if (!isOlderSettledThreadShell(thread, beforeMs)) return true; - counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1); - return ( - thread.pinnedAt != null || - (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || - isLiveSubagentShell(thread) || - ancestorIds.has(thread.id) - ); - }); - return { - threads, - olderSettled: { - before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), - asOfSequence: input.snapshotSequence, - counts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), - }, - }; + ); } /** - * The snapshot a shell subscriber gets for its opt-ins, and the subagent ids whose transcript - * events its stream drops (from every row, so an omitted child that wakes keeps that filter). + * A subscriber's active shell snapshot, read like `loadShellSnapshotParts`. With an opt-in, the + * store's index decides which shells to read, so threads the trims drop are never built. */ -export function shellSnapshotForSubscriber( - base: OrchestrationV2ShellSnapshot, - options: { - readonly omitIdleSubagents: boolean; - readonly omitOlderSettled: boolean; - readonly now: DateTime.Utc; - }, -): { - readonly snapshot: OrchestrationV2ShellSnapshot; - readonly subagentThreadIds: Set; -} { - const rows = activeShellRowsForSubscriber(base.threads, options.omitIdleSubagents); - if (!options.omitOlderSettled) { +export const loadSubscriberShellSnapshot = Effect.fn("ShellStream.loadSubscriberShellSnapshot")( + function* (input: { + readonly sql: SqlClient.SqlClient; + readonly listShellIndex: Effect.Effect, E1>; + readonly readThreads: ( + threadIds: ReadonlyArray | undefined, + ) => Effect.Effect, E2>; + readonly listProjects: Effect.Effect, E4>; + readonly latestSequence: Effect.Effect; + readonly options: ShellSubscriberOptions; + }) { + const trims = input.options.omitIdleSubagents || input.options.omitOlderSettled; + const read = yield* input.sql + .withTransaction( + Effect.gen(function* () { + const index = trims ? yield* input.listShellIndex : null; + return { + index, + decodeThreads: yield* input.readThreads( + index === null ? undefined : shellSubscriberThreadIds(index, input.options), + ), + projects: yield* input.listProjects, + snapshotSequence: yield* input.latestSequence, + }; + }), + ) + .pipe(NodeSqliteClient.readOnly); + const base = buildActiveShellSnapshot({ + projects: read.projects, + threads: yield* read.decodeThreads, + snapshotSequence: read.snapshotSequence, + }); + const facts = shellTrimFacts(read.index ?? base.threads); return { - snapshot: { ...base, threads: rows.threads }, - subagentThreadIds: rows.subagentThreadIds, + snapshot: trimShellSnapshot(base, facts, { ...input.options, now: yield* DateTime.now }), + subagentThreadIds: facts.subagentThreadIds, }; - } - const older = omitOlderSettledShells({ - threads: rows.threads, - snapshotSequence: base.snapshotSequence, - now: options.now, - }); - return { - snapshot: { ...base, threads: older.threads, olderSettled: older.olderSettled }, - subagentThreadIds: rows.subagentThreadIds, - }; -} + }, +); /** * One transaction covers the reads so they agree; the threads are decoded after it commits, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7a45a54a312f4..5c51b8c48b859 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -514,12 +514,9 @@ const make = Effect.gen(function* () { // Update parents first so children inherit names; reread each pass for newly updated descendants. const visited = new Set([threadId]); while (true) { - const snapshot = yield* threads.getShellSnapshot(); + const snapshot = yield* threads.getShellSnapshot({ worktreePath: worktreeCwd }); const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => - !visited.has(thread.id) && - thread.worktreePath === worktreeCwd && - thread.branch === oldBranch, + (thread) => !visited.has(thread.id) && thread.branch === oldBranch, ); if (sharing.length === 0) break; for (const thread of sharing) { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index cfbbd4a68a51d..7b79b21e88153 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -394,10 +394,15 @@ export interface ThreadManagementServiceShape { ProjectionRecords, ThreadManagementProjectionLoadError | ThreadManagementThreadNotFoundError >; - readonly getShellSnapshot: (options?: { - readonly location?: "active" | "archive"; - }) => Effect.Effect; + readonly getShellSnapshot: ( + options?: Pick< + ShellSnapshotOptions, + "location" | "projectId" | "unsettledOnly" | "worktreePath" | "threadIds" + >, + ) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; + readonly countThreads: Orchestrator.OrchestratorV2["Service"]["countThreads"]; + readonly listActiveShellIndex: Orchestrator.OrchestratorV2["Service"]["listActiveShellIndex"]; readonly listOlderSettledThreads: Orchestrator.OrchestratorV2["Service"]["listOlderSettledThreads"]; readonly readShellSnapshot: ( options?: Pick, @@ -1333,6 +1338,8 @@ const make = Effect.gen(function* () { getProjectThreadRecords, getShellSnapshot: orchestrator.getShellSnapshot, listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, + countThreads: orchestrator.countThreads, + listActiveShellIndex: orchestrator.listActiveShellIndex, listOlderSettledThreads: orchestrator.listOlderSettledThreads, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 61ff54d0c794f..c2c67eb04d9dc 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -3,7 +3,6 @@ import { EnvironmentHttpApi, type OrchestrationProjectShell, } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; import * as Predicate from "effect/Predicate"; @@ -32,11 +31,7 @@ import { } from "./threadHistoryPaging.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import { - buildActiveShellSnapshot, - loadShellSnapshotParts, - shellSnapshotForSubscriber, -} from "./ShellStream.ts"; +import { loadSubscriberShellSnapshot } from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -84,19 +79,19 @@ export const layer = HttpApiBuilder.group( readonly omitIdleSubagents: boolean; readonly omitOlderSettled: boolean; }) { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projectStore.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const { snapshot } = shellSnapshotForSubscriber(base, { - ...options, - now: yield* DateTime.now, + const { snapshot } = yield* loadSubscriberShellSnapshot({ + sql, + listShellIndex: threadManagement.listActiveShellIndex(), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ + location: "active", + ...(threadIds === undefined ? {} : { threadIds }), + }), + listProjects: projectStore.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + options, }); - const projects = yield* enrichProjectShells(base.projects); + const projects = yield* enrichProjectShells(snapshot.projects); return { ...snapshot, projects }; }, ); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 18004510baa45..3ba57db6d7e5a 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -457,15 +457,13 @@ export const make = Effect.gen(function* () { ) { const { projectId } = input; const snapshot = yield* threadProjections - .getShellSnapshot() + .getShellSnapshot({ projectId }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), ), ); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === projectId, - ); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads]; if (projectThreads.length > 0 && input.force !== true) { return yield* new ProjectNotEmptyError({ projectId }); } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 7f0e86bd6cf49..48c4a5c240ea9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -190,6 +190,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + countThreads: unused, + listActiveShellIndex: () => Effect.succeed([]), listOlderSettledThreads: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index cbe7b35ef409e..54742a0af7d66 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -726,10 +726,15 @@ export const make = Effect.gen(function* () { return "unlinked" as const; } const environmentId = yield* serverEnvironment.getEnvironmentId; - const [projectSnapshot, shellSnapshot] = yield* Effect.all([ - projects.snapshot, - threads.getShellSnapshot(), - ]); + const projectSnapshot = yield* projects.snapshot; + // Subagents never publish awareness, and they are most threads. + const topLevelThreadIds = (yield* threads.listActiveShellIndex()).flatMap((thread) => + thread.lineage.relationshipToParent === "subagent" ? [] : [thread.id], + ); + const shellSnapshot = yield* threads.getShellSnapshot({ + location: "active", + threadIds: topLevelThreadIds, + }); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, startedAt, diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 59f6b67f04509..549aae79e8be5 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -141,13 +141,7 @@ it.effect("parks automatic pull until activation without delaying command readin }), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), + countThreads: () => Effect.succeed(0), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({}), Layer.mock(ServiceLauncherClient.ServiceLauncherClient)({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 274b149c66efc..bef61bbca56fd 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -176,13 +176,8 @@ it.effect.each([ }), }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - threads: [], - archivedThreads: [], - schemaVersion: 1, - snapshotSequence: 0, - }), + countThreads: () => Effect.succeed(0), + listActiveShellIndex: () => Effect.succeed([]), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({ launch: (input) => { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index d8d62dc029c24..b812ad07fa6d6 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -153,11 +153,11 @@ const recordStartupHeartbeat = Effect.gen(function* () { const { threadCount, projectCount } = yield* Effect.all({ projects: projects.snapshot, - threads: threads.getShellSnapshot(), + threadCount: threads.countThreads(), }).pipe( - Effect.map(({ projects: projectSnapshot, threads: shellSnapshot }) => ({ + Effect.map(({ projects: projectSnapshot, threadCount }) => ({ projectCount: projectSnapshot.projects.length, - threadCount: shellSnapshot.threads.length + shellSnapshot.archivedThreads.length, + threadCount, })), Effect.catch((cause) => Effect.logWarning("failed to gather V2 startup counts for telemetry", { @@ -279,8 +279,7 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { title: path.basename(serverConfig.cwd) || "project", workspaceRoot: serverConfig.cwd, }); - const shell = yield* threads.getShellSnapshot(); - const existingThread = shell.threads.find( + const existingThread = (yield* threads.listActiveShellIndex()).find( (thread) => thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index bce9450778736..2c6adce82f390 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -412,12 +412,23 @@ it.effect("shell snapshots omit idle children and older settled threads only on Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), Effect.map(([item]) => (item?.kind === "snapshot" ? item.snapshot : null)), Effect.provideService(ThreadManagement.ThreadManagementService, { - readShellSnapshot: () => + listActiveShellIndex: () => + Effect.succeed( + threads.map((thread) => ({ + ...thread, + pinnedAt: null, + snoozedUntil: null, + mayBeLive: thread.activityRunStatus !== null, + })), + ), + readShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => Effect.succeed( Effect.succeed({ schemaVersion: 1, snapshotSequence: 0, - threads, + threads: threads.filter( + (thread) => options?.threadIds?.includes(thread.id) ?? true, + ), archivedThreads: [], }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 86c7a4db56af4..a7ef5dedf24fb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,7 +124,6 @@ import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { - buildActiveShellSnapshot, bufferShellLiveStream, coalesceShellApplicationEvents, composeShellStreamWithEnrichment, @@ -134,7 +133,7 @@ import { shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, - shellSnapshotForSubscriber, + loadSubscriberShellSnapshot, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -1010,21 +1009,23 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); const loadSnapshot = Effect.fn("ws.orchestrationV2.loadShellSnapshot")(function* () { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projects.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const forSubscriber = shellSnapshotForSubscriber(base, { - omitIdleSubagents: input.omitIdleSubagents === true, - omitOlderSettled: input.omitOlderSettled === true, - now: yield* DateTime.now, + const forSubscriber = yield* loadSubscriberShellSnapshot({ + sql, + listShellIndex: threadManagement.listActiveShellIndex(), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ + location: "active", + ...(threadIds === undefined ? {} : { threadIds }), + }), + listProjects: projects.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + options: { + omitIdleSubagents: input.omitIdleSubagents === true, + omitOlderSettled: input.omitOlderSettled === true, + }, }); subagentThreadIds = forSubscriber.subagentThreadIds; - const enriched = yield* enrichProjectShells(base.projects); + const enriched = yield* enrichProjectShells(forSubscriber.snapshot.projects); return { snapshot: { ...forSubscriber.snapshot, From 22c37a8da4567d59b0759396dd5412b28da9ab4a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:23:56 -0700 Subject: [PATCH 07/21] perf(server): preparing a run is 15x faster and uses 200x less memory (#27105) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ThreadLaunchService.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 5c51b8c48b859..b68eef4b2a40c 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -1063,12 +1063,14 @@ const make = Effect.gen(function* () { "ThreadLaunchService.prepareRun", )(function* (input) { const scheduled = yield* Effect.gen(function* () { - const projection = yield* threads.getThreadProjection(input.threadId); - const run = projection.runs.find((candidate) => candidate.id === input.runId); + const { thread, runs } = yield* threads.getThreadRecords(input.threadId, ["runs"], { + runIds: [input.runId], + }); + const run = runs.find((candidate) => candidate.id === input.runId); const workspacePreparation = run?.workspacePreparation; if (run?.status !== "preparing" || workspacePreparation === undefined) return; if (!(yield* reservePreparation(input.commandId))) return; - yield* scheduleRunPreparation(input, projection, run, workspacePreparation).pipe( + yield* scheduleRunPreparation(input, thread, run, workspacePreparation).pipe( Effect.onError(() => releasePreparation(input.commandId)), ); }).pipe(Effect.exit); @@ -1090,30 +1092,33 @@ const make = Effect.gen(function* () { return dispatched; }); - const scheduleRunPreparation = ( + const scheduleRunPreparation = Effect.fn("ThreadLaunchService.scheduleRunPreparation")(function* ( input: ThreadLaunchPreparationInput, - projection: OrchestrationV2ThreadProjection, + thread: OrchestrationV2ThreadProjection["thread"], run: OrchestrationV2ThreadProjection["runs"][number], workspacePreparation: ThreadLaunchWorkspaceStrategy, - ) => { - const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + ) { + const { messages } = yield* threads.getThreadRecords(input.threadId, ["messages"], { + messageIds: [run.userMessageId], + }); + const message = messages.find((candidate) => candidate.id === run.userMessageId); const reuse = workspacePreparation.type === "worktree" && - projection.thread.worktreePath !== null && - projection.thread.branch !== null + thread.worktreePath !== null && + thread.branch !== null ? { strategy: { type: "existing_worktree" as const, - worktreePath: projection.thread.worktreePath, - branch: projection.thread.branch, + worktreePath: thread.worktreePath, + branch: thread.branch, }, reusedWorktree: { baseRef: workspacePreparation.baseRef }, } : null; - return schedulePreparation( + return yield* schedulePreparation( { commandId: input.commandId, - projectId: projection.thread.projectId, + projectId: thread.projectId, workspaceStrategy: reuse?.strategy ?? workspacePreparation, ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }), ...(message === undefined @@ -1129,7 +1134,7 @@ const make = Effect.gen(function* () { input.threadId, run.id, ); - }; + }); return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); }); From 2f4da5de4e6ce094da698e969dfa245f6891d8f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:28:21 -0700 Subject: [PATCH 08/21] =?UTF-8?q?feat(server):=20traces=20name=20what=20gr?= =?UTF-8?q?ew=20the=20heap,=20for=20~0.3=C2=B5s=20per=20span=20(#27104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- .../src/observability/HeapAttribution.test.ts | 69 +++++++++++ .../src/observability/HeapAttribution.ts | 108 ++++++++++++++++++ .../server/src/observability/Observability.ts | 6 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/observability/HeapAttribution.test.ts create mode 100644 apps/server/src/observability/HeapAttribution.ts diff --git a/apps/server/src/observability/HeapAttribution.test.ts b/apps/server/src/observability/HeapAttribution.test.ts new file mode 100644 index 0000000000000..b355ba5c35e1f --- /dev/null +++ b/apps/server/src/observability/HeapAttribution.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; + +import * as HeapAttribution from "./HeapAttribution.ts"; + +const MB = 1024 * 1024; + +it.effect("names the span that grew the heap in a spike, not an older open or ended one", () => + Effect.gen(function* () { + const ended: Array = []; + const inner = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + ended.push(span); + }; + return span; + }, + }); + let heap = 300 * MB; + const attribution = yield* HeapAttribution.make(inner, () => heap); + yield* attribution.run; + // The sampler's baseline: heap before the spans allocate. + yield* TestClock.adjust("1 second"); + const open = (name: string) => + attribution.tracer.span({ + name, + parent: Option.none(), + annotations: Context.empty(), + links: [], + startTime: 0n, + kind: "internal", + root: false, + sampled: true, + }); + + const subscription = open("ws.rpc.orchestration.subscribeShell"); + const old = open("ProjectionStore.getSnapshot"); + // Slow drift never spikes, but leaves the long-lived span far above its start. + for (let step = 0; step < 5; step++) { + heap += 80 * MB; + yield* TestClock.adjust("11 seconds"); + if (step === 1) old.end(1n, undefined as never); + } + const small = open("ProjectionStore.getThreadShell"); + const big = open("ProjectionStore.getThreadProjection"); + heap += 2 * MB; + small.end(1n, undefined as never); + heap += 198 * MB; + yield* TestClock.adjust("2 seconds"); + + const spike = ended.find((span) => span.name === "server.heap.spike"); + assert.isDefined(spike); + assert.deepEqual(spike!.attributes.get("suspects"), [ + "ProjectionStore.getThreadProjection +200 MB", + ]); + big.end(2n, undefined as never); + subscription.end(3n, undefined as never); + assert.equal(big.attributes.get("memory.heap_growth_bytes"), 200 * MB); + assert.isUndefined(small.attributes.get("memory.heap_growth_bytes")); + assert.equal(old.attributes.get("memory.heap_growth_bytes"), 160 * MB); + }), +); diff --git a/apps/server/src/observability/HeapAttribution.ts b/apps/server/src/observability/HeapAttribution.ts new file mode 100644 index 0000000000000..4b18efd0fb911 --- /dev/null +++ b/apps/server/src/observability/HeapAttribution.ts @@ -0,0 +1,108 @@ +// @effect-diagnostics nodeBuiltinImport:off - only node:v8 exposes a heap reading cheap enough to take per span. +import * as NodeV8 from "node:v8"; + +import * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; +import * as Tracer from "effect/Tracer"; + +const MB = 1024 * 1024; +// Smaller growth is noise from concurrent fibers; an attribute on every span would bloat exports. +const SPAN_GROWTH_ATTRIBUTE_BYTES = 8 * MB; +const SPIKE_GROWTH_BYTES = 128 * MB; +const SPIKE_WINDOW_SAMPLES = 10; +const SPIKE_COOLDOWN_SAMPLES = 60; +const RECENT_SPANS = 32; +const REPORTED_SPANS = 8; + +const readHeapUsed = () => NodeV8.getHeapStatistics().used_heap_size; + +interface Growth { + readonly name: string; + readonly growthBytes: number; +} + +/** + * Heap growth is approximate: it includes other fibers' allocations and GC, so it names + * suspects, not culprits. A spike lists what was open or just grew. + */ +export const make = (tracer: Tracer.Tracer, heapUsed: () => number = readHeapUsed) => + Effect.sync(() => { + const open = new Map< + Tracer.Span, + { readonly name: string; readonly startHeap: number; readonly startTick: number } + >(); + const recent: Array = []; + const samples: Array = []; + let cooldown = 0; + let tick = 0; + + const wrapped = Tracer.make({ + ...(tracer.context === undefined ? {} : { context: tracer.context }), + span(options) { + const span = tracer.span(options); + const startHeap = heapUsed(); + open.set(span, { name: options.name, startHeap, startTick: tick }); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + open.delete(span); + const growthBytes = heapUsed() - startHeap; + if (growthBytes >= SPAN_GROWTH_ATTRIBUTE_BYTES) { + span.attribute("memory.heap_growth_bytes", growthBytes); + recent.push({ name: options.name, growthBytes, endTick: tick }); + if (recent.length > RECENT_SPANS) recent.shift(); + } + end(endTime, exit); + }; + return span; + }, + }); + + // Untraced: it runs every second and only emits a span when the heap spikes. + const sample = Effect.fnUntraced(function* () { + const heap = heapUsed(); + tick++; + samples.push(heap); + if (samples.length > SPIKE_WINDOW_SAMPLES) samples.shift(); + if (cooldown > 0) { + cooldown--; + return; + } + const growthBytes = heap - Math.min(...samples); + if (growthBytes < SPIKE_GROWTH_BYTES) return; + cooldown = SPIKE_COOLDOWN_SAMPLES; + const suspects = [ + // Spans open since before the window, such as subscriptions, would claim all of its growth. + ...[...open.values()] + .filter(({ startTick }) => tick - startTick <= samples.length) + .map(({ name, startHeap }) => ({ name, growthBytes: heap - startHeap })), + ...recent.filter(({ endTick }) => tick - endTick <= samples.length), + ] + .filter((suspect) => suspect.growthBytes >= SPAN_GROWTH_ATTRIBUTE_BYTES) + .toSorted((left, right) => right.growthBytes - left.growthBytes) + .slice(0, REPORTED_SPANS) + .map(({ name, growthBytes }) => `${name} +${Math.round(growthBytes / MB)} MB`); + recent.length = 0; + // A span, not a log: this fiber starts before the server logger is provided. + yield* Effect.void.pipe( + Effect.withSpan("server.heap.spike", { + root: true, + level: "Warn", + attributes: { + heapUsedMb: Math.round(heap / MB), + growthMb: Math.round(growthBytes / MB), + suspects, + }, + }), + ); + }); + + const run: Effect.Effect = sample().pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.forever, + Effect.withTracer(wrapped), + Effect.forkScoped, + Effect.asVoid, + ); + + return { tracer: wrapped, run }; + }); diff --git a/apps/server/src/observability/Observability.ts b/apps/server/src/observability/Observability.ts index 343851e3d1d11..166d8ac194a8c 100644 --- a/apps/server/src/observability/Observability.ts +++ b/apps/server/src/observability/Observability.ts @@ -16,6 +16,7 @@ import * as ServerConfig from "../config.ts"; import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; import * as ServerLogger from "../serverLogger.ts"; import * as BrowserTraceCollector from "./BrowserTraceCollector.ts"; +import * as HeapAttribution from "./HeapAttribution.ts"; export const layer = Layer.unwrap( Effect.gen(function* () { @@ -71,8 +72,11 @@ export const layer = Layer.unwrap( ...(delegate ? { delegate } : {}), }); + const heapAttribution = yield* HeapAttribution.make(tracer); + yield* heapAttribution.run; + return Layer.mergeAll( - Layer.succeed(Tracer.Tracer, tracer), + Layer.succeed(Tracer.Tracer, heapAttribution.tracer), BrowserTraceCollector.layer(sink), ); }), From 52f4ec6f9420abc8f4c033528f95397fd2716f0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:35:12 -0700 Subject: [PATCH 09/21] perf(gitcafe): PR sync makes 2x fewer GitCafe requests for unchanged PRs (#27100) Co-authored-by: Claude Opus 5.5 --- .../server/GitCafePullRequestProvider.test.ts | 69 +++++++++++++------ .../src/server/GitCafePullRequestProvider.ts | 63 +++++++++++++---- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 7c257f037003e..113a388e853bb 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -1226,43 +1226,70 @@ describe("GitCafePullRequestProvider", () => { it.effect("recounts summary lines only when the compared commits move", () => { const landedBase = "fedcba9876543210fedcba9876543210fedcba98"; + const mainTip = "0123012301230123012301230123012301230123"; let comparisonBaseOid = baseOid; + let tips = { targetBranch: "main", observedBaseOid: baseOid }; let pullReads = 0; + let listingCut = true; + let hunksDown = false; + const hunks = (request: SentRequest) => ({ + version: 4, + headOid, + comparisonBaseOid, + items: (request.body as { paths: ReadonlyArray }).paths.map((path) => ({ + path, + additions: comparisonBaseOid === baseOid ? 3 : 1, + deletions: 1, + })), + }); const server = fakeGitCafe({ // A comment moves the version without moving the compared commits. - "GET /repos/owner/repo/pulls/7": () => ({ ...pull, version: 4 + pullReads++ }), - "GET /repos/owner/repo/pulls/7/changes": () => ({ - version: 4, - headOid, - comparisonBaseOid, - items: [{ path: "a.ts" }, { path: "b.ts" }], - next: null, - }), - "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({ + "GET /repos/owner/repo/pulls/7": () => ({ ...pull, ...tips, version: 4 + pullReads++ }), + "GET /repos/owner/repo/pulls/7/changes": (request: SentRequest) => ({ version: 4, headOid, comparisonBaseOid, - items: (request.body as { paths: ReadonlyArray }).paths.map((path) => ({ - path, - additions: comparisonBaseOid === baseOid ? 3 : 1, - deletions: 1, - })), + ...(!listingCut + ? { items: [{ path: "a.ts" }, { path: "b.ts" }], next: null } + : request.query.has("after") + ? { items: [], next: "more" } + : { items: [{ path: "a.ts" }], next: "more" }), }), + get "POST /repos/owner/repo/pulls/7/diff-files"() { + return hunksDown + ? new Response(encodeJson({ type: "https://cafe.sh/errors/conflict" }), { status: 409 }) + : hunks; + }, }); - const hunkReads = () => - server.sent.filter((request) => request.path.endsWith("/diff-files")).length; + const reads = (suffix: string) => + server.sent.filter((request) => request.path.endsWith(suffix)).length; return Effect.gen(function* () { const provider = yield* GitCafePullRequestProvider.make; + // A listing that stops before its last page holds nothing for its commits. + yield* provider.getChangeRequestSummary!(target); + listingCut = false; + hunksDown = true; + // Unread hunks leave the lines unknown, and are read again next time. + const unread = yield* provider.getChangeRequestSummary!(target); + assert.strictEqual(unread.additions, undefined); + hunksDown = false; const first = yield* provider.getChangeRequestSummary!(target); const second = yield* provider.getChangeRequestSummary!(target); assert.deepInclude(first, { additions: 6, deletions: 2, changedFiles: 2 }); assert.deepInclude(second, { additions: 6, deletions: 2, changedFiles: 2 }); - assert.strictEqual(hunkReads(), 1); - // A landed parent layer moves the comparison base under the same head. - comparisonBaseOid = landedBase; + // Neither the head nor the target tip moved, so the file list is not read again. + assert.deepStrictEqual([reads("/changes"), reads("/diff-files")], [4, 3]); + // The target branch advancing keeps the merge base, so only the file list is read. + tips = { ...tips, observedBaseOid: mainTip }; const third = yield* provider.getChangeRequestSummary!(target); - assert.deepInclude(third, { additions: 2, deletions: 2, changedFiles: 2 }); - assert.strictEqual(hunkReads(), 2); + assert.deepInclude(third, { additions: 6, deletions: 2, changedFiles: 2 }); + assert.deepStrictEqual([reads("/changes"), reads("/diff-files")], [5, 3]); + // A landed parent layer retargets the pull and moves the comparison base under the same head. + comparisonBaseOid = landedBase; + tips = { targetBranch: "trunk", observedBaseOid: mainTip }; + const fourth = yield* provider.getChangeRequestSummary!(target); + assert.deepInclude(fourth, { additions: 2, deletions: 2, changedFiles: 2 }); + assert.deepStrictEqual([reads("/changes"), reads("/diff-files")], [6, 4]); }).pipe(Effect.provide(server.layer)); }); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 1d4b7338f3c12..e87a4e154d69b 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -636,11 +636,19 @@ export const make = Effect.gen(function* () { // so hunk totals are held per compared pair of commits. The file list that names the pair is // cheap; a retarget or a landed parent layer moves the base without moving the head. const lineTotals = new Map(); + // The compared pair follows from the head and the target tip the pull reports, so while neither + // moves the stats are reused without listing the files again. + const statsByTips = new Map< + string, + { readonly changedFiles: number; readonly additions?: number; readonly deletions?: number } + >(); + const remember = (cache: Map, key: K, value: V) => { + if (cache.size >= LINE_STATS_CACHE_SIZE) cache.delete(cache.keys().next().value!); + cache.set(key, value); + }; - const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( - input: PullRef, - version: number, - ) { + // readLineStats owns the span. + const countLines = Effect.fnUntraced(function* (input: PullRef, version: number) { const path = yield* pullPath("getChangeRequest", input); const files: Array = []; let revision: { version: number; headOid: string; baseOid: string } | undefined; @@ -667,13 +675,23 @@ export const make = Effect.gen(function* () { after = batch.next; if (after === null || after === previous || batch.items.length === 0) break; } - if (revision === undefined) return { changedFiles: 0, additions: 0, deletions: 0 }; + if (revision === undefined) + return { + headOid: null, + complete: true, + stats: { changedFiles: 0, additions: 0, deletions: 0 }, + }; const key = `${input.host} ${input.repository.toLowerCase()} ${input.number} ${revision.baseOid} ${revision.headOid}`; const held = lineTotals.get(key); - if (held !== undefined) return { changedFiles: files.length, ...held }; + const { headOid } = revision; + // A listing that stopped before its last page counts only part of the diff, so nothing is held. + const listed = after === null; + if (held !== undefined) + return { headOid, complete: listed, stats: { changedFiles: files.length, ...held } }; + const tooMany = files.length > LINE_STATS_MAX_FILES; const details = files.some((file) => file.additions !== undefined) ? Option.some(files) - : files.length > LINE_STATS_MAX_FILES + : tooMany ? Option.none() : yield* Effect.forEach( chunk(detailablePaths(files), DIFF_FILES_BATCH), @@ -684,16 +702,33 @@ export const make = Effect.gen(function* () { Effect.option, ); // Unread hunks leave the lines unknown rather than zero; the file count still stands. - if (Option.isNone(details)) return { changedFiles: files.length }; + if (Option.isNone(details)) + return { headOid, complete: listed && tooMany, stats: { changedFiles: files.length } }; const counted = details.value; const totals = { additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0), deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0), }; - if (lineTotals.size >= LINE_STATS_CACHE_SIZE) - lineTotals.delete(lineTotals.keys().next().value!); - lineTotals.set(key, totals); - return { changedFiles: files.length, ...totals }; + if (listed) remember(lineTotals, key, totals); + return { headOid, complete: listed, stats: { changedFiles: files.length, ...totals } }; + }); + + const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( + input: PullRef, + pull: Json.GitCafePullDetail, + ) { + const tipsKey = + pull.headOid === null || pull.observedBaseOid == null + ? null + : `${input.host} ${input.repository.toLowerCase()} ${input.number} ${pull.targetBranch} ${pull.observedBaseOid} ${pull.headOid}`; + const heldStats = tipsKey === null ? undefined : statsByTips.get(tipsKey); + if (heldStats !== undefined) return heldStats; + const stats = yield* countLines(input, pull.version); + // A failed hunk read is retried next time rather than held as unknown, and a head that moved + // between the two reads would hold another pair's stats. + if (tipsKey !== null && stats.complete && stats.headOid === pull.headOid) + remember(statsByTips, tipsKey, stats.stats); + return stats.stats; }); const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* ( @@ -870,7 +905,7 @@ export const make = Effect.gen(function* () { const changes = pull.headOid === null ? null - : yield* readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)); + : yield* readLineStats(input, pull).pipe(Effect.orElseSucceed(() => null)); // A count that could not be read is absent rather than zero. const { additions: _additions, @@ -904,7 +939,7 @@ export const make = Effect.gen(function* () { ), pull.headOid === null ? Effect.succeed(null) - : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)), + : readLineStats(input, pull).pipe(Effect.orElseSucceed(() => null)), // Reviewers need `pr:review:read`, which a contents-only token lacks. readPages("getChangeRequest", input, `${path}/reviewers`, Json.GitCafeActorEntries).pipe( // @effect-diagnostics-next-line catchConditionalRefailToCatchIf:off - catchIf widens this paginated result to any in tsc-rs. From facaa08b36c5511108752aa08d48182c37412ce4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:38:31 -0700 Subject: [PATCH 10/21] perf(shared): SQLite reads stop queueing behind each other, up to 5x faster under load (#27097) Co-authored-by: Claude Opus 5.5 --- packages/shared/src/nodeSqliteClient.test.ts | 40 ++++ packages/shared/src/nodeSqliteClient.ts | 232 ++++++++++++------- 2 files changed, 183 insertions(+), 89 deletions(-) diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts index 99eb8b37a215b..ac786993fa0bf 100644 --- a/packages/shared/src/nodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -238,6 +238,27 @@ it.effect("lets the writer commit while a read-only transaction is open", () => ), ); +it.effect("serves another read while a read-only transaction is open", () => + withReaderClient((sql) => + Effect.gen(function* () { + const opened = yield* Deferred.make(); + const other = yield* Effect.forkChild( + Deferred.await(opened).pipe(Effect.andThen(countEntries(sql).pipe(SqliteClient.readOnly))), + ); + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* countEntries(sql); + yield* Deferred.succeed(opened, undefined); + // One shared reader would make this join wait on the transaction forever. + assert.equal(yield* Fiber.join(other), 1); + }), + ) + .pipe(SqliteClient.readOnly); + }), + ), +); + // regression: #19226 read-only transactions run on the separate reader connection it.effect("rejects a write in a read-only transaction", () => withReaderClient((sql) => @@ -333,6 +354,25 @@ it.effect("uses the writer when the reader cannot open the database", () => assert.equal(yield* sql.withTransaction(countEntries(sql)).pipe(SqliteClient.readOnly), 1); assert.equal(yield* sql.withTransaction(countEntries(sql)).pipe(SqliteClient.readOnly), 1); + + // A read beside an open read transaction must not start a worker on the next slot. + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const holder = yield* sql + .withTransaction( + countEntries(sql).pipe( + Effect.andThen(Deferred.succeed(held, undefined)), + Effect.andThen(Deferred.await(release)), + ), + ) + .pipe(SqliteClient.readOnly, Effect.forkChild); + yield* Deferred.await(held); + const other = yield* countEntries(sql).pipe(SqliteClient.readOnly, (effect) => + Effect.forkChild(effect, { startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(holder); + assert.equal(yield* Fiber.join(other), 1); assert.equal(startedWorkers.length, startedBefore + 1); }), ), diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index a42943c065ac0..c0edfdb868d1f 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -293,8 +293,14 @@ const failed = (cause: unknown, message: string, operation: string) => // Read-transaction cleanup succeeds after worker exit because it can no longer change anything. const endsReadTransaction = (sql: string) => /^\s*ROLLBACK\b|^\s*COMMIT\b/i.test(sql); +// WAL readers run concurrently; one worker serialized every read behind the slowest transaction. +const READER_POOL_SIZE = 4; + // Bind transactions to one worker; fail after its exit instead of resuming outside the transaction on a replacement. -const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { +const makeReader = Effect.fnUntraced(function* ( + options: SqliteClientConfig, + pool: { unavailable: boolean }, +) { let current: | { readonly worker: NodeWorkerThreads.Worker; @@ -302,7 +308,6 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { readonly started: Promise; } | undefined; - let unavailable = false; let nextRequestId = 0; const start = () => { @@ -439,8 +444,8 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { void reader.worker.terminate(); } // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. - if (unavailable) return Effect.succeed(undefined); - unavailable = true; + if (pool.unavailable) return Effect.succeed(undefined); + pool.unavailable = true; return Effect.logWarning("SQLite reader worker unavailable; reads use the main connection", { reason, }).pipe(Effect.as(undefined)); @@ -448,7 +453,7 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { return Effect.suspend((): Effect.Effect => { // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. - if (unavailable) return Effect.succeed(undefined); + if (pool.unavailable) return Effect.succeed(undefined); let reader: NonNullable; try { reader = current ?? start(); @@ -467,7 +472,7 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { }), (startup): Effect.Effect => // A late ready event must not hand out a worker retired by another acquire timeout. - unavailable || current !== reader + pool.unavailable || current !== reader ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. Effect.succeed(undefined) : Option.isSome(startup) && startup.value.type === "ready" @@ -1073,21 +1078,48 @@ const make = Effect.fn("makeWithDatabase")(function* ( const semaphore = yield* Semaphore.make(1); const connection = yield* makeConnection; - const acquireReader = - options.readerWorker && isFileDatabase ? yield* makeReader(options) : undefined; - const readerSemaphore = yield* Semaphore.make(1); + // A reader that cannot start turns off the whole pool, so routing never picks a dead slot. + const readerPool = { unavailable: false }; + const readers = + options.readerWorker && isFileDatabase + ? yield* Effect.forEach(Array.from({ length: READER_POOL_SIZE }), () => + Effect.map( + Effect.all([makeReader(options, readerPool), Semaphore.make(1)]), + ([acquire, lock]) => ({ + acquire, + lock, + claims: 0, + }), + ), + ) + : []; const writer = { name: "writer" as const, lock: semaphore, connection }; let activeWriter: ConnectionOwner | undefined; - const route = Effect.withFiber((fiber) => - acquireReader !== undefined && Context.get(fiber.context, ReadOnly) - ? Effect.map(acquireReader, (readerConnection) => + // Callers must run route uninterruptibly and pass `claimed` a finalizer that runs `release` once. + const route = (claimed: (release: Effect.Effect) => Effect.Effect) => + Effect.withFiber((fiber) => { + if (readers.length === 0 || readerPool.unavailable || !Context.get(fiber.context, ReadOnly)) { + return Effect.succeed(writer); + } + // Least claimed wins and ties go to the lowest slot, so idle slots never start a worker. + let slot = readers[0]!; + for (const candidate of readers) if (candidate.claims < slot.claims) slot = candidate; + slot.claims++; + const reader = slot; + return Effect.andThen( + claimed( + Effect.sync(() => { + reader.claims--; + }), + ), + Effect.map(reader.acquire, (readerConnection) => readerConnection === undefined ? writer - : { name: "reader" as const, lock: readerSemaphore, connection: readerConnection }, - ) - : Effect.succeed(writer), - ); + : { name: "reader" as const, lock: reader.lock, connection: readerConnection }, + ), + ); + }); const acquired = ( target: { readonly name: Acquired["connection"] }, @@ -1101,88 +1133,110 @@ const make = Effect.fn("makeWithDatabase")(function* ( const writerBorrowBatchSize = 16; let writerBorrowsSinceYield = 0; const borrower: Borrower = (use) => - Effect.flatMap(route, (target) => - Effect.withFiber((fiber) => { - const clock = fiber.getRef(Clock.Clock); - const start = clock.monotonicTimeNanosUnsafe(); - const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; - const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; - const useConnection = Effect.suspend(() => { - if (target.name === "writer") activeWriter = owner; - if ( - fiber.getRef(References.TracerEnabled) && - fiber.getRef(Statement.SpanPropagationEnabled) - ) { - annotateAcquired(fiber, acquired(target, clock, start), ownerAtWaitStart); - } - return use(target.connection); - }); - const useOwnedConnection = - target.name === "writer" - ? Effect.onExit(useConnection, () => - Effect.sync(() => { - if (activeWriter === owner) activeWriter = undefined; - }), - ) - : useConnection; - if (!options.checkpointWorker || target.name !== "writer") { - return target.lock.withPermits(1)(useOwnedConnection); - } - const writerUse = Effect.onExit(useOwnedConnection, (exit) => - Effect.sync(() => { - writerBorrowsSinceYield = Exit.hasInterrupts(exit) - ? 0 - : Math.min(writerBorrowsSinceYield + 1, writerBorrowBatchSize); - }), - ); - return Effect.flatMap(target.lock.withPermitsIfAvailable(1)(writerUse), (result) => - Option.isSome(result) - ? Effect.succeed(result.value) - : target.lock.withPermits(1)( - Effect.andThen( - // Failed SQL also consumes the batch; cancellation hands off immediately. - Effect.suspend(() => { - if (writerBorrowsSinceYield < writerBorrowBatchSize) return Effect.void; - writerBorrowsSinceYield = 0; - return Effect.callback((resume) => { - const immediate = setImmediate(() => resume(Effect.void)); - return Effect.sync(() => clearImmediate(immediate)); - }); - }), - writerUse, - ), - ), - ); - }), - ); - const transactionAcquirer = Effect.flatMap(route, (target) => Effect.uninterruptibleMask((restore) => { - const fiber = Fiber.getCurrent()!; - const scope = Context.getUnsafe(fiber.context, Scope.Scope); + let release: Effect.Effect = Effect.void; + const claimed = (next: Effect.Effect) => + Effect.sync(() => { + release = next; + }); + return Effect.flatMap(route(claimed), (target) => restore(borrowTarget(target, use))).pipe( + Effect.ensuring(Effect.suspend(() => release)), + ); + }); + const borrowTarget = ( + target: { + readonly name: Acquired["connection"]; + readonly lock: Semaphore.Semaphore; + readonly connection: Connection; + }, + use: (connection: Connection) => Effect.Effect, + ): Effect.Effect => + Effect.withFiber((fiber) => { const clock = fiber.getRef(Clock.Clock); const start = clock.monotonicTimeNanosUnsafe(); - const pending = fiber.getRef(TransactionAcquire); const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; - const acquire = Effect.tap((pending?.restore ?? restore)(target.lock.take(1)), () => { + const useConnection = Effect.suspend(() => { if (target.name === "writer") activeWriter = owner; - if (pending !== undefined) { - pending.acquired = acquired(target, clock, start); - pending.ownerAtWaitStart = ownerAtWaitStart; + if ( + fiber.getRef(References.TracerEnabled) && + fiber.getRef(Statement.SpanPropagationEnabled) + ) { + annotateAcquired(fiber, acquired(target, clock, start), ownerAtWaitStart); } - return Scope.addFinalizer( - scope, - Effect.sync(() => { - if (target.name === "writer" && activeWriter === owner) activeWriter = undefined; - }).pipe(Effect.andThen(target.lock.release(1))), - ); + return use(target.connection); }); - return Effect.as( - // A scheduler yield after taking the permit must not precede its release finalizer. - Effect.provideService(acquire, Scheduler.PreventSchedulerYield, true), - target.connection, + const useOwnedConnection = + target.name === "writer" + ? Effect.onExit(useConnection, () => + Effect.sync(() => { + if (activeWriter === owner) activeWriter = undefined; + }), + ) + : useConnection; + if (!options.checkpointWorker || target.name !== "writer") { + return target.lock.withPermits(1)(useOwnedConnection); + } + const writerUse = Effect.onExit(useOwnedConnection, (exit) => + Effect.sync(() => { + writerBorrowsSinceYield = Exit.hasInterrupts(exit) + ? 0 + : Math.min(writerBorrowsSinceYield + 1, writerBorrowBatchSize); + }), ); - }), + return Effect.flatMap(target.lock.withPermitsIfAvailable(1)(writerUse), (result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : target.lock.withPermits(1)( + Effect.andThen( + // Failed SQL also consumes the batch; cancellation hands off immediately. + Effect.suspend(() => { + if (writerBorrowsSinceYield < writerBorrowBatchSize) return Effect.void; + writerBorrowsSinceYield = 0; + return Effect.callback((resume) => { + const immediate = setImmediate(() => resume(Effect.void)); + return Effect.sync(() => clearImmediate(immediate)); + }); + }), + writerUse, + ), + ), + ); + }); + const claimedUntilScopeCloses = (release: Effect.Effect) => + Effect.withFiber((fiber) => + Scope.addFinalizer(Context.getUnsafe(fiber.context, Scope.Scope), release), + ); + const transactionAcquirer = Effect.uninterruptible(route(claimedUntilScopeCloses)).pipe( + Effect.flatMap((target) => + Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()!; + const scope = Context.getUnsafe(fiber.context, Scope.Scope); + const clock = fiber.getRef(Clock.Clock); + const start = clock.monotonicTimeNanosUnsafe(); + const pending = fiber.getRef(TransactionAcquire); + const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; + const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; + const acquire = Effect.tap((pending?.restore ?? restore)(target.lock.take(1)), () => { + if (target.name === "writer") activeWriter = owner; + if (pending !== undefined) { + pending.acquired = acquired(target, clock, start); + pending.ownerAtWaitStart = ownerAtWaitStart; + } + return Scope.addFinalizer( + scope, + Effect.sync(() => { + if (target.name === "writer" && activeWriter === owner) activeWriter = undefined; + }).pipe(Effect.andThen(target.lock.release(1))), + ); + }); + return Effect.as( + // A scheduler yield after taking the permit must not precede its release finalizer. + Effect.provideService(acquire, Scheduler.PreventSchedulerYield, true), + target.connection, + ); + }), + ), ); const client = yield* Client.make({ From 7c29f2d20be6d26ed3914cc4e075681ad1aa5db2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:39:24 -0700 Subject: [PATCH 11/21] perf(server): git status runs in parallel and repo detection spawns 3x fewer git processes (#27103) Co-authored-by: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriver.test.ts | 18 +++ apps/server/src/vcs/GitVcsDriver.ts | 46 +++++-- apps/server/src/vcs/GitVcsDriverCore.ts | 120 ++++++++++-------- apps/server/src/vcs/VcsDriverRegistry.test.ts | 14 +- apps/server/src/vcs/VcsProcess.ts | 3 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 48 ++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 25 +++- 7 files changed, 194 insertions(+), 80 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index c7a06c3bdc31f..88ca1eed8f5ea 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1006,6 +1006,24 @@ it.effect("GitVcsDriver treats a missing git executable as outside any repositor }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "GitVcsDriver detects a repository whose path contains a newline", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-newline-repo-" }); + const cwd = path.join(parent, "multi\nline"); + yield* fileSystem.makeDirectory(cwd); + yield* runGit(cwd, ["init"]); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + const repository = yield* driver.detectRepository(cwd); + assert.strictEqual(repository?.rootPath, yield* fileSystem.realPath(cwd)); + assert.strictEqual(repository?.metadataPath, ".git"); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "GitVcsDriver still fails detection when git exists but cannot be spawned", () => diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index acbf817462c12..95833200a1b5f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -587,25 +587,43 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { - if (!(yield* isInsideWorkTree(cwd))) { - return null; - } - - const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ - "rev-parse", - "--show-toplevel", - ]); - const gitCommonDir = yield* gitCommand( + // One spawn answers all three; outside a work tree it exits non-zero or prints "false". + const result = yield* gitCommand( vcsProcess, - "GitVcsDriver.detectRepository.commonDir", + "GitVcsDriver.detectRepository", cwd, - ["rev-parse", "--git-common-dir"], - ).pipe(Effect.orElseSucceed(() => null)); + ["rev-parse", "--is-inside-work-tree", "--show-toplevel", "--git-common-dir"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 16_384, + }, + ).pipe(Effect.catchIf(isMissingExecutable, () => Effect.succeed(null))); + if (result === null || result.exitCode !== 0) return null; + const lines = result.stdout.replace(/\r?\n$/u, "").split(/\r?\n/u); + if (lines[0]?.trim() !== "true") return null; + // A newline inside a path breaks the line framing; ask for each path on its own. + if (lines.length !== 3) { + const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ + "rev-parse", + "--git-common-dir", + ]).pipe(Effect.orElseSucceed(() => null)); + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + } return { kind: "git" as const, - rootPath: root.stdout.trim(), - metadataPath: gitCommonDir?.stdout.trim() || null, + rootPath: lines[1]!.trim(), + metadataPath: lines[2]!.trim() || null, freshness: yield* nowFreshness(), }; }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c486c6744cb5f..03f0286ce50d7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2091,7 +2091,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* lockError; } } - const statusResult = yield* executeGitWithStableDiagnostics( + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const statusCacheKey = repositoryPaths?.gitCommonDir; + const readStatus = executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, statusArgs, @@ -2105,34 +2109,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), }), ); - - if (statusResult === null) { - return NON_REPOSITORY_STATUS_DETAILS; - } - - if (statusResult.exitCode !== 0) { - if (isNonRepositoryGitStderr(statusResult.stderr)) { - return NON_REPOSITORY_STATUS_DETAILS; - } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.statusDetails.status", - cwd, - args: statusArgs, - }), - detail: "Git status failed.", - exitCode: statusResult.exitCode, - stdoutLength: statusResult.stdout.length, - stderrLength: statusResult.stderr.length, - }); - } - - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + // diff-index does not read status output, so both spawn together; a non-repository discards it. + const [statusResult, numstatResult, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ + readStatus, // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", @@ -2178,6 +2158,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); }), + Effect.result, ), statusCacheKey ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) @@ -2188,6 +2169,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: "unbounded" }, ); + if (statusResult === null) { + return NON_REPOSITORY_STATUS_DETAILS; + } + + if (statusResult.exitCode !== 0) { + if (isNonRepositoryGitStderr(statusResult.stderr)) { + return NON_REPOSITORY_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.status", + cwd, + args: statusArgs, + }), + detail: "Git status failed.", + exitCode: statusResult.exitCode, + stdoutLength: statusResult.stdout.length, + stderrLength: statusResult.stderr.length, + }); + } + + const numstatEntries = yield* Effect.fromResult(numstatResult); const statusStdout = statusResult.stdout; let refName: string | null = null; @@ -2231,25 +2234,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const fallbackAheadCount = - includeDivergence && !upstreamRef && refName - ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) - : null; - - if (fallbackAheadCount !== null) { - aheadCount = fallbackAheadCount; - behindCount = 0; - } - const isDefaultBranch = refName !== null && (refName === defaultBranch || (defaultBranch === null && (refName === "main" || refName === "master"))); - if (includeDivergence && refName && !isDefaultBranch) { - aheadOfDefaultCount = - fallbackAheadCount !== null - ? fallbackAheadCount - : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); + const needsFallbackAhead = includeDivergence && !upstreamRef && refName !== null; + const needsAheadOfDefault = includeDivergence && refName !== null && !isDefaultBranch; + const [baseAheadCount, branchChanges] = yield* Effect.all( + [ + refName !== null && (needsFallbackAhead || needsAheadOfDefault) + ? computeAheadCountAgainstBase(cwd, refName, { + knownOriginDefaultBranch: defaultBranch, + }).pipe(Effect.orElseSucceed(() => 0)) + : Effect.succeed(0), + options?.includeBranchChanges + ? readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName, { + knownOriginDefaultBranch: defaultBranch, + }).pipe(Effect.orElseSucceed(() => undefined)) + : Effect.undefined, + ], + { concurrency: "unbounded" }, + ); + + if (needsFallbackAhead) { + aheadCount = baseAheadCount; + behindCount = 0; + } + if (needsAheadOfDefault) { + aheadOfDefaultCount = baseAheadCount; } const fileStatMap = new Map(); @@ -2276,12 +2288,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } files.sort((a, b) => a.path.localeCompare(b.path)); - const branchChanges = options?.includeBranchChanges - ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe( - Effect.orElseSucceed(() => undefined), - ) - : undefined; - return { isRepo: true, hasOriginRemote: hasPrimaryRemote, @@ -3014,6 +3020,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, branch: string | null, explicitBaseRef?: string, + knownOriginDefaultBranch: string | null = null, ) { const base = explicitBaseRef !== undefined @@ -3025,9 +3032,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : explicitBaseRef, }) : branch - ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe( - Effect.orElseSucceed(() => null), - ) + ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { + allowRemoteOfCurrent: true, + knownOriginDefaultBranch, + }).pipe(Effect.orElseSucceed(() => null)) : null; if (base === null) return { baseRef: null, mergeBase: "HEAD" }; const { baseRef, comparisonRef } = base; @@ -3058,8 +3066,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* ( cwd: string, branch: string | null, + options?: { readonly knownOriginDefaultBranch: string | null }, ) { - const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch); + const { baseRef, mergeBase } = yield* resolveReviewMergeBase( + cwd, + branch, + undefined, + options?.knownOriginDefaultBranch ?? null, + ); const untracked = yield* prepareUntrackedReviewIndex(cwd, []); if (untracked === null) { return yield* new GitCommandError({ diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index e5c8169774830..b6f7dae813744 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -21,6 +21,8 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => args[0] === "-C" && args.length >= 2 ? args.slice(2) : args; +const DETECT_COMMAND = "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir"; + describe("VcsDriverRegistry", () => { const makeDiskBackedLayer = (rootDir: string, calls: string[]) => Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( @@ -49,10 +51,10 @@ describe("VcsDriverRegistry", () => { stderr: "fatal: not a git repository", }; } - if (command === "rev-parse --is-inside-work-tree") return processOutput("true\n"); - if (command === "rev-parse --show-toplevel") return processOutput(`${repoDir}\n`); - if (command === "rev-parse --git-common-dir") { - return processOutput(`${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`); + if (command === DETECT_COMMAND) { + return processOutput( + `true\n${repoDir}\n${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`, + ); } return processOutput(""); }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), @@ -75,7 +77,7 @@ describe("VcsDriverRegistry", () => { yield* fs.remove(path.join(repoDir, ".git"), { recursive: true }); assert.equal(yield* registry.detect({ cwd: repoDir }), null); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -118,7 +120,7 @@ describe("VcsDriverRegistry", () => { yield* fs.makeDirectory(path.join(repoDir, ".git")); assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index e2055e3400531..00218397481ff 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -83,8 +83,7 @@ const gitMetricOperations = new Set([ "GitVcsDriver.checkpoints.resolveGitCommonDir", "GitVcsDriver.checkpoints.resolveHeadCommit", "GitVcsDriver.checkpoints.restoreCheckpoint", - "GitVcsDriver.detectRepository.commonDir", - "GitVcsDriver.detectRepository.root", + "GitVcsDriver.detectRepository", "GitVcsDriver.filterIgnoredPaths", "GitVcsDriver.initRepository", "GitVcsDriver.isInsideWorkTree", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index cc1b6d24e3873..042f8274543e5 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -76,6 +76,7 @@ function layerTestFor(state: { backgroundWorkEnabled?: boolean; automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; + beforeLocalStatus?: Effect.Effect; fileSystem?: FileSystem.FileSystem; pullCurrentBranch?: GitWorkflowService.GitWorkflowService["Service"]["pullCurrentBranch"]; }) { @@ -95,9 +96,11 @@ function layerTestFor(state: { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => - Effect.sync(() => { + Effect.suspend(() => { state.localStatusCalls += 1; - return state.currentLocalStatus; + return (state.beforeLocalStatus ?? Effect.void).pipe( + Effect.as(state.currentLocalStatus), + ); }), currentBranch: () => Effect.sync(() => state.currentLocalStatus.refName), remoteStatus: (_input, options) => @@ -163,6 +166,47 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b } describe("VcsStatusBroadcaster", () => { + it.effect("refreshes queued behind a running one share the next git status read", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeLocalStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const running = yield* broadcaster + .refreshLocalStatus("/repo") + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.await(started); + // Requested while the first read runs, so that read may predate their change. + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + const queued = yield* Effect.all( + [broadcaster.refreshLocalStatus("/repo"), broadcaster.refreshLocalStatus("/repo")], + { concurrency: "unbounded" }, + ).pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + + assert.isFalse((yield* Fiber.join(running)).hasWorkingTreeChanges); + const [second, third] = yield* Fiber.join(queued); + assert.isTrue(second.hasWorkingTreeChanges); + assert.isTrue(third.hasWorkingTreeChanges); + assert.strictEqual(state.localStatusCalls, 2); + + yield* broadcaster.refreshLocalStatus("/repo"); + assert.strictEqual(state.localStatusCalls, 3); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("publishes a terminal branch switch without reading remote status", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 8ce1b225d123d..a8839d0c1cd0c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -359,11 +359,30 @@ export const make = Effect.gen(function* () { ); }); + // A refresh that started after a request saw every change that request could mean, so queued + // callers share it instead of each running git status again. + const localRefreshLocks = yield* KeyedLock.make(); + const localRefreshRequests = new Map(); + const localRefreshResults = new Map< + string, + { readonly startedAt: number; readonly local: VcsStatusLocalResult } + >(); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( function* (cwd: string) { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + const requestedAt = (localRefreshRequests.get(cwd) ?? 0) + 1; + localRefreshRequests.set(cwd, requestedAt); + return yield* localRefreshLocks.withLock( + cwd, + Effect.gen(function* () { + const shared = localRefreshResults.get(cwd); + if (shared !== undefined && shared.startedAt >= requestedAt) return shared.local; + const startedAt = localRefreshRequests.get(cwd) ?? requestedAt; + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + localRefreshResults.set(cwd, { startedAt, local }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }), + ); }, ); From 65b0ab8c91c1d2c6495460f13b91166c57068076 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:42:47 -0700 Subject: [PATCH 12/21] fix(client): dead connection routes are probed 30x less often (#27101) Co-authored-by: Claude Opus 5.5 --- .../client-runtime/src/connection/driver.ts | 18 +-- .../src/connection/registry.test.ts | 6 +- .../src/connection/supervisor.test.ts | 135 +++++++++++++++++- .../src/connection/supervisor.ts | 66 ++++++++- 4 files changed, 208 insertions(+), 17 deletions(-) diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index 0fd08c63364e7..2cdf012187efb 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -40,6 +40,8 @@ export interface EnvironmentConnectionLease { } export type RouteCheck = "answered" | "silent" | "unchecked"; +/** "unprepared" means the route answered its check but could not be prepared. */ +export type RoutePreflight = "ready" | "unprepared" | "silent" | "unchecked"; const ROUTE_CHECK_TIMEOUT_MS = 2_500; // Preferred direct routes share one head start, so a relay connection does not @@ -57,7 +59,7 @@ export class ConnectionDriver extends Context.Service< readonly preflight: ( entry: ConnectionCatalogEntry, route: ConnectionRoute, - ) => Effect.Effect; + ) => Effect.Effect; /** Asks the host behind a live session to answer a fresh request on the session's own origin. */ readonly checkHost: (prepared: PreparedConnection) => Effect.Effect; } @@ -204,13 +206,13 @@ export const make = Effect.gen(function* () { ? Effect.succeed("silent" as const) : checkRoute(entry, route) ).pipe( - Effect.flatMap((check) => - check === "answered" - ? resolver.prepare(routeEntry(entry, route)).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ) - : Effect.succeed(false), + Effect.filterOrElse( + (check): check is Exclude => check !== "answered", + () => + resolver.prepare(routeEntry(entry, route)).pipe( + Effect.as("ready"), + Effect.orElseSucceed((): RoutePreflight => "unprepared"), + ), ), Effect.withSpan("ConnectionDriver.preflight"), ); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index c99e950200b23..ec8863949cef7 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -431,7 +431,11 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ), ), preflight: (_entry, route) => - checkRoute(route).pipe(Effect.map((check) => check === "answered")), + checkRoute(route).pipe( + Effect.map((check): ConnectionDriver.RoutePreflight => + check === "answered" ? "ready" : check, + ), + ), checkHost: () => Effect.succeed("unchecked"), }); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 347bde9cc44c8..66d151043b115 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -148,6 +148,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: readonly initialConfig?: (attempt: number) => Effect.Effect; readonly checkRoute?: (route: ConnectionRoute) => Effect.Effect; readonly checkHost?: (prepared: PreparedConnection) => Effect.Effect; + readonly preflight?: (route: ConnectionRoute) => Effect.Effect; readonly inboundFrames?: Effect.Effect; }) { const networkStatus = yield* SubscriptionRef.make( @@ -244,7 +245,12 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ConnectionDriver.ConnectionDriver.of({ connect, preflight: (_entry, route) => - checkRoute(route).pipe(Effect.map((check) => check === "answered")), + options?.preflight?.(route) ?? + checkRoute(route).pipe( + Effect.map((check): ConnectionDriver.RoutePreflight => + check === "answered" ? "ready" : check, + ), + ), checkHost: (prepared) => options?.checkHost?.(prepared) ?? Effect.succeed("unchecked"), @@ -1614,6 +1620,133 @@ describe("EnvironmentSupervisor routes", () => { }), ); + it.effect("checks a dead LAN route less often until the network changes", () => + Effect.gen(function* () { + const lanReachable = yield* Ref.make(false); + const lanChecks = yield* Ref.make(0); + const harness = yield* makeHarness({ + checkRoute: (route) => + route.target._tag === "BearerConnectionTarget" + ? Ref.update(lanChecks, (count) => count + 1).pipe( + Effect.andThen(Ref.get(lanReachable)), + Effect.map((reachable) => (reachable ? "answered" : "silent")), + ) + : Effect.succeed("unchecked"), + prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), + }); + const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* Ref.set(lanChecks, 0); + + // Checked at 1, 3 and 7 minutes instead of every minute. + for (let minute = 0; minute < 10; minute += 1) { + yield* TestClock.adjust("60 seconds"); + } + expect(yield* Ref.get(lanChecks)).toBe(3); + + yield* Ref.set(lanReachable, true); + yield* harness.wake("network-changed"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( + "BearerConnectionTarget", + ); + }), + ); + + it.effect("keeps checking a LAN route every minute when it answers but cannot be prepared", () => + Effect.gen(function* () { + const lanPreflights = yield* Ref.make(0); + const harness = yield* makeHarness({ + checkRoute: (route) => + Effect.succeed(route.target._tag === "BearerConnectionTarget" ? "silent" : "unchecked"), + preflight: (route) => + route.target._tag === "BearerConnectionTarget" + ? Ref.update(lanPreflights, (count) => count + 1).pipe(Effect.as("unprepared" as const)) + : Effect.succeed("unchecked"), + prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), + }); + const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + for (let minute = 0; minute < 10; minute += 1) { + yield* TestClock.adjust("60 seconds"); + } + expect(yield* Ref.get(lanPreflights)).toBe(10); + }), + ); + + it.effect("ignores a dead LAN check that was running when the network changed", () => + Effect.gen(function* () { + const lanChecks = yield* Ref.make(0); + const checkStarted = yield* Deferred.make(); + const finishCheck = yield* Deferred.make(); + const woke = yield* Ref.make(false); + const wakeupHandled = yield* Deferred.make(); + const harness = yield* makeHarness({ + checkRoute: (route) => + route.target._tag === "BearerConnectionTarget" + ? Ref.updateAndGet(lanChecks, (count) => count + 1).pipe( + Effect.flatMap((check) => + check === 1 + ? Effect.succeed("silent" as const) + : check === 2 + ? Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishCheck)), + Effect.as("silent" as const), + ) + : Effect.succeed("answered" as const), + ), + ) + : Effect.succeed("unchecked"), + prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), + // The wakeup probes the session only after it forgets the silent routes. + probe: () => + Ref.get(woke).pipe( + Effect.flatMap((afterWake) => + afterWake ? Deferred.succeed(wakeupHandled, undefined) : Effect.void, + ), + Effect.asVoid, + ), + }); + const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* TestClock.adjust("60 seconds"); + yield* Deferred.await(checkStarted); + yield* Ref.set(woke, true); + yield* harness.wake("network-changed"); + yield* Deferred.await(wakeupHandled); + yield* Deferred.succeed(finishCheck, undefined); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( + "BearerConnectionTarget", + ); + }), + ); + it.effect("moves off a dead LAN socket when the phone switches to cellular", () => Effect.gen(function* () { const lanReachable = yield* Ref.make(true); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index ce7632df0f52b..212381921961b 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -58,7 +58,11 @@ const SILENT_SOCKET_RECHECK_EVERY = "1 second"; // instead of timing out. const REMOTE_SILENT_SOCKET_AFTER = "2500 millis"; const BACKOFF_RESET_AFTER_MS = 30_000; -const BETTER_ROUTE_CHECK_INTERVAL = "60 seconds"; +const BETTER_ROUTE_CHECK_INTERVAL_MS = 60_000; +// Each check of a dead route can wait out the full probe timeout, so a route +// that keeps failing is checked half as often each time, until the network +// changes and it may answer again. +const SILENT_ROUTE_BACKOFF_MAX_MS = 1_800_000; // A better route that answered the check but then failed to connect is not // tried again for this long, so a flaky LAN cannot bounce the connection. const BETTER_ROUTE_COOLDOWN_MS = 300_000; @@ -139,6 +143,20 @@ function retryDelayMs(failureCount: number, random: number): number { return Math.round(ceiling / 2 + (ceiling / 2) * random); } +interface SilentRoute { + readonly failures: number; + readonly until: bigint; +} + +function backOffSilentRoute(previous: SilentRoute | undefined, now: bigint): SilentRoute { + const failures = (previous?.failures ?? 0) + 1; + const delayMs = Math.min( + SILENT_ROUTE_BACKOFF_MAX_MS, + BETTER_ROUTE_CHECK_INTERVAL_MS * 2 ** failures, + ); + return { failures, until: now + BigInt(delayMs) * 1_000_000n }; +} + function annotateTarget(target: ConnectionTarget) { return Effect.annotateCurrentSpan({ "environment.id": target.environmentId, @@ -288,6 +306,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const currentRoutes = Ref.get(currentEntry).pipe(Effect.map(connectionRoutes)); const preferredRouteId = yield* Ref.make(Option.none()); const routeCooldowns = yield* Ref.make>(new Map()); + // The generation keeps a check that started before a reset from recording + // its stale failures after it. + const silentRoutes = yield* Ref.make({ + generation: 0, + routes: new Map() as ReadonlyMap, + }); + const forgetSilentRoutes = Ref.update(silentRoutes, ({ generation }) => ({ + generation: generation + 1, + routes: new Map(), + })); const attemptEntry = Effect.gen(function* () { const latest = yield* Ref.get(currentEntry); @@ -338,15 +366,29 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if (current <= 0) return; const now = yield* Clock.monotonicTimeNanos; const cooldowns = yield* Ref.get(routeCooldowns); + const silent = yield* Ref.get(silentRoutes); const better = connectionRoutes(latest) .slice(0, current) - .filter((route) => (cooldowns.get(connectionRouteId(route.target)) ?? 0n) <= now); - const passed = yield* Effect.forEach( + .filter((route) => { + const id = connectionRouteId(route.target); + return (cooldowns.get(id) ?? 0n) <= now && (silent.routes.get(id)?.until ?? 0n) <= now; + }); + const results = yield* Effect.forEach( better, (route: ConnectionRoute) => driver.preflight(latest, route), { concurrency: "unbounded" }, ); - const index = passed.indexOf(true); + yield* Ref.update(silentRoutes, (previous) => { + if (previous.generation !== silent.generation) return previous; + const routes = new Map(previous.routes); + for (const [position, route] of better.entries()) { + const id = connectionRouteId(route.target); + if (results[position] !== "silent") routes.delete(id); + else routes.set(id, backOffSilentRoute(previous.routes.get(id), now)); + } + return { generation: previous.generation, routes }; + }); + const index = results.indexOf("ready"); if (index === -1) return; const live = yield* SubscriptionRef.get(session); if (Option.isNone(live) || live.value !== lease.session) return; @@ -668,7 +710,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); // Ticks always run: the check skips the preferred route itself, and the // route list can grow while connected (learned routes). - yield* Stream.tick(BETTER_ROUTE_CHECK_INTERVAL).pipe( + yield* Stream.tick(Duration.millis(BETTER_ROUTE_CHECK_INTERVAL_MS)).pipe( Stream.drop(1), Stream.runForEach(() => requestBetterRouteCheck(lease)), Effect.forkScoped, @@ -1114,14 +1156,24 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( current.network === network ? [false, current] : ([true, { ...current, network }] as const), ).pipe( Effect.flatMap((changed) => - changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void, + changed + ? (network === "offline" ? Effect.void : forgetSilentRoutes).pipe( + Effect.andThen(signal({ _tag: "NetworkChanged", network })), + ) + : Effect.void, ), ), ), Effect.forkScoped, ); yield* wakeups.changes.pipe( - Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })), + Stream.runForEach((reason) => + // A long suspension can hide a network change the platform never reported. + (reason === "network-changed" || reason === "application-active-long" + ? forgetSilentRoutes + : Effect.void + ).pipe(Effect.andThen(signal({ _tag: "Wakeup", reason }))), + ), Effect.forkScoped, ); yield* Queue.take(betterRouteChecks).pipe( From dd148481531a0a090780a2ade20588566db099a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:43:23 -0700 Subject: [PATCH 13/21] perf(server): idle servers run 100x fewer background queries and 4.8M fewer spans a day (#27098) Co-authored-by: Claude Opus 5.5 --- .../ThreadSettlementService.test.ts | 23 ++++++ .../ThreadSettlementService.ts | 9 ++- .../server/src/persistence/DatabaseStorage.ts | 24 +++++-- .../ScheduledTaskService.schedule.test.ts | 62 ++++++++++++++++ .../scheduledTasks/ScheduledTaskService.ts | 70 ++++++++++++++++++- apps/server/src/serverSettings.test.ts | 52 +++++++++++++- apps/server/src/serverSettings.ts | 47 +++++++++---- 7 files changed, 262 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 012e3d2f234ca..0a0996c0ede0c 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -842,6 +842,29 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("stops settling the rest of a sweep once auto-settlement is turned off", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + // Sweeps settle 8 threads at a time, so the ninth starts after a dispatch turned it off. + const threads = Array.from({ length: 9 }, (_, index) => makeThread(`idle-${index}`)); + let disable: Effect.Effect = Effect.void; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot(threads), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 3 }, + onDispatch: () => disable, + }); + disable = fixture.updateSettings({ sidebarAutoSettleAfterDays: null }); + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + const settled = (yield* Ref.get(fixture.commands)).map((command) => command.threadId); + expect(settled).not.toContain(threads[8]!.id); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("reevaluates immediately after a pull request merge", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 509dcd0307dc2..6a3d4fffb0399 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -321,8 +321,10 @@ export const make = Effect.gen(function* () { // Merge events resweep all candidates because unlinked threads settle through branch lookup. const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs)); - const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")( + // Runs per candidate thread, thousands per sweep on busy servers; the sweep owns the span. + const settleThread = Effect.fnUntraced( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { + // thread.auto-settle does not recheck settings, so read them per decision; reads are cached. const currentSettings = resolveProjectSettings( yield* settingsService.getSettings, thread.projectId, @@ -414,7 +416,8 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); - const wouldSettle = Effect.fn("ThreadSettlementServiceV2.wouldSettle")(function* ( + // wouldSettle and pullRequestFor run per lookup group; the git and pull request reads they make carry the spans. + const wouldSettle = Effect.fnUntraced(function* ( group: ReadonlyArray<(typeof lookupCandidates)[number]>, pullRequest: SettlementPullRequest, ) { @@ -435,7 +438,7 @@ export const make = Effect.gen(function* () { }); }); - const pullRequestFor = Effect.fn("ThreadSettlementServiceV2.pullRequestFor")(function* ( + const pullRequestFor = Effect.fnUntraced(function* ( group: ReadonlyArray<(typeof lookupCandidates)[number]>, ) { const thread = group[0]!; diff --git a/apps/server/src/persistence/DatabaseStorage.ts b/apps/server/src/persistence/DatabaseStorage.ts index 4907bc20f15b0..bbb4dd1399efd 100644 --- a/apps/server/src/persistence/DatabaseStorage.ts +++ b/apps/server/src/persistence/DatabaseStorage.ts @@ -49,17 +49,30 @@ const make = Effect.gen(function* () { const doctorTargetsThisDatabase = path.resolve(config.dbPath) === path.resolve(config.baseDir, "userdata", path.basename(config.dbPath)); + // Only VACUUM changes these, and doctor refuses to VACUUM while a server has the database open. + let layout: { readonly pageSize: number; readonly incrementalVacuum: boolean } | undefined; + const readLayout = Effect.suspend(() => + layout !== undefined + ? Effect.succeed(layout) + : Effect.gen(function* () { + const size = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`; + const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; + layout = { + pageSize: size[0]?.page_size ?? 0, + incrementalVacuum: mode[0]?.auto_vacuum === 2, + }; + return layout; + }), + ); const publishStatus = Effect.gen(function* () { if (!doctorTargetsThisDatabase) return; + const { pageSize, incrementalVacuum } = yield* readLayout; const pages = yield* sql<{ readonly page_count: number }>`PRAGMA page_count`; const free = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`; - const size = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`; - const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; - const pageSize = size[0]?.page_size ?? 0; const next: DatabaseStorageStatus = { databaseBytes: (pages[0]?.page_count ?? 0) * pageSize, reclaimableBytes: (free[0]?.freelist_count ?? 0) * pageSize, - incrementalVacuumEnabled: mode[0]?.auto_vacuum === 2, + incrementalVacuumEnabled: incrementalVacuum, compactCommand, }; // Every set reaches each open Storage page; most passes change nothing. @@ -71,8 +84,7 @@ const make = Effect.gen(function* () { ); const reclaim = Effect.gen(function* () { - const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; - if (mode[0]?.auto_vacuum !== 2) return; + if (!(yield* readLayout).incrementalVacuum) return; // The driver steps once per statement; incremental_vacuum frees one page per step. Yield between pages. for (let page = 0; page < RECLAIM_PAGES_PER_PASS; page++) { const free = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`; diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 09e1d7674044f..1070b49a0dcf2 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -167,3 +168,64 @@ it.effect("keeps the task list order when a toggle or edit rewrites updated_at", }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect( + "idle polls still wake for tasks saved later, recorded due times, and beside far-future tasks", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const launched = yield* Deferred.make(); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: (input) => + Deferred.succeed(launched, input.title).pipe( + Effect.as({ threadId: "thread-idle-poll", resumed: false } as never), + ), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + // The first poll finds no tasks and remembers that nothing is due. + yield* TestClock.adjust("6 seconds"); + // Its year-10000+ next run sorts first as text; it must not hide the task below. + yield* service.upsert( + yield* decodeUpsertInput({ + title: "Far future", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 3e14 }, + projectId: "project-idle-poll", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + const created = yield* service.upsert( + yield* decodeUpsertInput({ + title: "Idle poll", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: "project-idle-poll", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + // This poll records the new task's due time; the later ones must still fire it. + yield* TestClock.adjust("6 seconds"); + yield* TestClock.adjust("60 seconds"); + expect(yield* Deferred.await(launched)).toBe(created.task.title); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index b3aa9440944bd..f05811ed65110 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -396,6 +396,35 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct return tasks; }); +// Backstop for a write path that forgets to invalidate; the tick still runs every few seconds. +const DUE_HINT_MAX_AGE_MS = 10 * 60 * 1_000; + +/** + * Remembers when a poller next has work so idle scheduler ticks skip SQL. + * `invalidate` after every write that can move work earlier; a tick records + * only if no write happened since it read `version`. + */ +const makeDueHint = () => { + let version = 0; + let hint: { readonly dueAtMs: number; readonly checkedAtMs: number } | null = null; + return { + invalidate: Effect.sync(() => { + version++; + hint = null; + }), + version: Effect.sync(() => version), + isIdle: (nowMs: number) => + Effect.sync( + () => + hint !== null && hint.dueAtMs > nowMs && nowMs - hint.checkedAtMs < DUE_HINT_MAX_AGE_MS, + ), + record: (readVersion: number, dueAtMs: number, nowMs: number) => + Effect.sync(() => { + if (readVersion === version) hint = { dueAtMs, checkedAtMs: nowMs }; + }), + }; +}; + export const layer = Layer.effect( ScheduledTaskService, Effect.gen(function* () { @@ -426,7 +455,13 @@ export const layer = Layer.effect( const activeRuns = yield* Ref.make>(new Set()); // Notifications re-emit the full list; slow subscribers need only the newest dirty signal. const changesPubSub = yield* PubSub.sliding(1); - const notifyChanged = PubSub.publish(changesPubSub, undefined).pipe(Effect.asVoid); + const dueTasks = makeDueHint(); + const pendingWebhooks = makeDueHint(); + // Every scheduled_tasks write that can change next_run_at, enabled or run state notifies. + const notifyChanged = dueTasks.invalidate.pipe( + Effect.andThen(PubSub.publish(changesPubSub, undefined)), + Effect.asVoid, + ); const selectAllRows = () => sql` @@ -972,6 +1007,28 @@ export const layer = Layer.effect( const runDueTasks = Effect.fn("ScheduledTaskService.runDueTasks")(function* () { const now = yield* localNow; + const nowMs = DateTime.toEpochMillis(now); + if (yield* dueTasks.isIdle(nowMs)) return; + const readVersion = yield* dueTasks.version; + const [next] = yield* sql<{ next_run_at: string | null }>` + SELECT MIN(next_run_at) AS next_run_at FROM scheduled_tasks + WHERE enabled = 1 AND next_run_at IS NOT NULL AND last_run_status <> 'running' + `.pipe( + NodeSqliteClient.readOnly, + Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), + ); + // Compare as text like listDueTasks: expanded years (`+011533-…`) sort first but parse far ahead. + const nextRunAt = next?.next_run_at ?? null; + if (nextRunAt === null || nextRunAt > iso(now)) { + const nextDueAt = + nextRunAt === null + ? Option.some(Infinity) + : DateTime.make(nextRunAt).pipe(Option.map(DateTime.toEpochMillis)); + if (Option.isSome(nextDueAt) && nextDueAt.value > nowMs) { + yield* dueTasks.record(readVersion, nextDueAt.value, nowMs); + } + return; + } const tasks = yield* listDueTasks(now).pipe( Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); @@ -1695,6 +1752,8 @@ export const layer = Layer.effect( const triggerWebhook: ScheduledTaskService["Service"]["triggerWebhook"] = (request) => triggerWebhookUnobserved(request).pipe( + // Accepted deliveries may have queued a coalescing window. + Effect.ensuring(pendingWebhooks.invalidate), Effect.tapError(() => observeDelivery(request, "error", undefined, undefined)), Metrics.withMetrics({ timer: Metrics.webhookDeliveryDuration }), Effect.withSpan("ScheduledTaskService.triggerWebhook", { @@ -2090,10 +2149,19 @@ export const layer = Layer.effect( const dispatchPending = Effect.fn("ScheduledTaskService.dispatchPendingWebhooks")(function* () { const now = DateTime.toEpochMillis(yield* localNow); + if (yield* pendingWebhooks.isIdle(now)) return; + const readVersion = yield* pendingWebhooks.version; yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; const tasks = yield* sql<{ task_id: ScheduledTaskId }>` SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') `.pipe(NodeSqliteClient.readOnly); + // Workers can leave rows behind to retry, so keep polling while any are claimed. + if (tasks.length === 0) { + const [next] = yield* sql<{ due_at: number | null }>` + SELECT MIN(due_at) AS due_at FROM scheduled_task_webhook_pending WHERE state = 'pending' + `.pipe(NodeSqliteClient.readOnly); + yield* pendingWebhooks.record(readVersion, next?.due_at ?? Infinity, now); + } yield* Effect.forEach( tasks, ({ task_id: taskId }) => diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index f34b653c8cbc8..8b7d81859b70d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1047,6 +1047,54 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("reads settings secrets once per settings change", () => { + let reads = 0; + const layerSecret = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.ServerSecretStore, (store) => ({ + ...store, + get: (name: string) => + Effect.suspend(() => { + reads++; + return store.get(name); + }), + })), + ).pipe(Layer.provide(ServerSecretStore.layer)); + const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(layerSecret), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-settings-secret-cache-test-" }), + ), + ), + ); + return Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_cached"); + const service = yield* ServerSettingsModule.ServerSettingsService; + const save = (value: string) => + service.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "API_TOKEN", value, sensitive: true }], + config: {}, + }, + }, + }); + const token = service.getSettings.pipe( + Effect.map((settings) => settings.providerInstances[instanceId]?.environment?.[0]?.value), + ); + yield* save("sk-first"); + reads = 0; + assert.equal(yield* token, "sk-first"); + assert.equal(yield* token, "sk-first"); + assert.equal(reads, 0); + yield* save("sk-second"); + assert.equal(yield* token, "sk-second"); + }).pipe(Effect.provide(layerSettings)); + }); + it.effect("keeps the inline value on disk when secret migration fails", () => { const cause = new ServerSecretStore.SecretStorePersistError({ resource: "provider environment secret", @@ -1578,6 +1626,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fileSystem = yield* FileSystem.FileSystem; let failRename = false; let settingsPathToFail: string | undefined; + let readDuringWrite: Effect.Effect = Effect.void; const writeFailure = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -1588,7 +1637,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ...fileSystem, rename: (fromPath, toPath) => failRename && toPath === settingsPathToFail - ? Effect.fail(writeFailure) + ? readDuringWrite.pipe(Effect.andThen(Effect.fail(writeFailure))) : fileSystem.rename(fromPath, toPath), }); const instanceId = ProviderInstanceId.make("antigravity_write_failure"); @@ -1610,6 +1659,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); failRename = true; + readDuringWrite = Effect.ignore(serverSettings.getSettings); const failedUpdate = yield* serverSettings .updateProviderInstance({ operation: "upsert", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 6f6a1db5a292a..48839bb8535d9 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -851,6 +851,31 @@ const make = Effect.gen(function* () { }, ); + // Background sweeps read settings every few seconds; reading secret files each time is wasted. + // Every secret write rewrites the settings file, so a new cached object implies fresh secrets. + // A read that overlaps a secret write may see files a rollback then restores, so it is not kept. + let materialized: { readonly source: ServerSettings; readonly value: ServerSettings } | null = + null; + let secretWriteGeneration = 0; + const invalidateMaterialized = Effect.sync(() => { + secretWriteGeneration++; + materialized = null; + }); + const getMaterializedSettings = getSettingsFromCache.pipe( + Effect.flatMap((source) => { + if (materialized?.source === source) return Effect.succeed(materialized.value); + const generation = secretWriteGeneration; + return materializeSettingsSecrets(source).pipe( + Effect.map(resolveTextGenerationProvider), + Effect.tap((value) => + Effect.sync(() => { + if (generation === secretWriteGeneration) materialized = { source, value }; + }), + ), + ); + }), + ); + const materializeChanges = (changes: Stream.Stream) => changes.pipe( Stream.mapEffect((settings) => @@ -1026,8 +1051,9 @@ const make = Effect.gen(function* () { const updated = yield* update(current); const persisted = yield* persistSettingsSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); - const materialized = yield* Effect.uninterruptibleMask(() => + const materializedNext = yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { + yield* invalidateMaterialized; const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); if (Exit.isFailure(materializedExit)) { @@ -1040,22 +1066,18 @@ const make = Effect.gen(function* () { return yield* Effect.failCause(writeExit.cause); } return materializedExit.value; - }), + }).pipe(Effect.ensuring(invalidateMaterialized)), ); yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() }); + const resolved = resolveTextGenerationProvider(materializedNext); + materialized = { source: next, value: resolved }; yield* emitChange(next); - return resolveTextGenerationProvider(materialized); + return resolved; }), ); const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => - writeSemaphore.withPermits(1)( - getSettingsFromCache.pipe( - Effect.flatMap(materializeSettingsSecrets), - Effect.map(resolveTextGenerationProvider), - Effect.flatMap(use), - ), - ); + writeSemaphore.withPermits(1)(getMaterializedSettings.pipe(Effect.flatMap(use))); const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { @@ -1160,10 +1182,7 @@ const make = Effect.gen(function* () { return { start, ready: Deferred.await(startedDeferred), - getSettings: getSettingsFromCache.pipe( - Effect.flatMap(materializeSettingsSecrets), - Effect.map(resolveTextGenerationProvider), - ), + getSettings: getMaterializedSettings, updateSettings: (patch) => updateAndPersistSettings((current) => Effect.succeed(applyServerSettingsPatch(current, patch)), From 812c0a71c785c69c34a0111c3ce6976f64cd5bfe Mon Sep 17 00:00:00 2001 From: Nathan Schroeder <49214985+SchroederNathan@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:36:08 -0400 Subject: [PATCH 14/21] feat(devices): support Android EAS Simulator sessions (#26797) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- apps/server/src/device/DeviceHubProxy.test.ts | 26 ++++ apps/server/src/device/DeviceHubProxy.ts | 12 +- apps/server/src/device/EasDeviceHost.test.ts | 87 ++++++++++- apps/server/src/device/EasDeviceHost.ts | 120 +++++++++------ .../components/device/DeviceControlsRail.tsx | 20 +-- .../web/src/components/device/DevicePanel.tsx | 2 +- .../web/src/components/device/DeviceSetup.tsx | 2 +- .../src/components/device/DeviceWorkspace.tsx | 17 ++- docs/internals/devices.md | 17 ++- docs/user/devices.md | 2 +- .../client-runtime/src/device/stream.test.ts | 138 +++++++++++++++++- packages/client-runtime/src/device/stream.ts | 62 +++++--- 12 files changed, 405 insertions(+), 100 deletions(-) diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 07b45737f6730..72c8f9e0e5d56 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -169,6 +169,32 @@ describe("device hub proxy", () => { expect(headers[0]?.["content-type"]).toBe("application/json"); }); + it("lets a viewer read Android stream capabilities and signal WebRTC with read scope", async () => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const hub = "http://t3.test/api/device-hub/vendor/serve-emu"; + const api = await handler(new Request(`${hub}/api?device=session-1`)); + expect(api.status).toBe(200); + await api.text(); + for (const action of ["offer", "close"]) { + const signal = await handler( + new Request(`${hub}/webrtc/${action}?device=session-1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"sessionId":"viewer"}', + }), + ); + expect(signal.status).toBe(200); + await signal.text(); + } + expect(requests).toEqual([ + "http://hub.test/vendor/serve-emu/api?device=session-1", + "http://hub.test/vendor/serve-emu/webrtc/offer?device=session-1", + "http://hub.test/vendor/serve-emu/webrtc/close?device=session-1", + ]); + expect((await handler(new Request(`${hub}/api`, { method: "POST" }))).status).toBe(405); + expect((await handler(new Request(`${hub}/webrtc/offer`))).status).toBe(405); + }); + it("sends requests a host routes itself to its upstream with its credentials", async () => { const { handler, requests, headers } = fixture( [AuthOrchestrationReadScope], diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 89cad0d7206e2..c1aaab3c61219 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -27,14 +27,17 @@ const ALLOWED_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/helper\/[^/]+\/panel\/(1|3)\/stream\.avcc$/, /^\/vendor\/serve-sim\/helper\/[^/]+\/webrtc\/(offer|close)$/, /^\/vendor\/serve-sim\/appstate$/, + /^\/vendor\/serve-emu\/api$/, /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility|fold)$/, /^\/vendor\/serve-emu\/health$/, ]; +// Paths that take methods besides GET; one missing from ALLOWED_PATHS takes no GET. const MUTABLE_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/api\/screenshot$/, // WebRTC signalling: opening and closing a view of the stream, so read scope. /^\/vendor\/serve-sim\/helper\/[^/]+\/webrtc\/(offer|close)$/, + /^\/vendor\/serve-emu\/webrtc\/(offer|close)$/, /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/, /^\/vendor\/serve-emu\/api\/fold$/, ]; @@ -178,14 +181,15 @@ const handler = Effect.gen(function* () { } const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/"; const upgrade = isWebSocketUpgrade(request); - const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) => - pattern.test(hubPath), - ); + const matches = (paths: ReadonlyArray) => paths.some((pattern) => pattern.test(hubPath)); + const allowed = upgrade + ? matches(ALLOWED_WS_PATHS) + : matches(ALLOWED_PATHS) || matches(MUTABLE_PATHS); if (!allowed) { return HttpServerResponse.text("Not Found", { status: 404 }); } const readOnly = request.method === "GET" || request.method === "HEAD"; - if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) { + if (!upgrade && !matches(readOnly ? ALLOWED_PATHS : MUTABLE_PATHS)) { return HttpServerResponse.text("Method Not Allowed", { status: 405 }); } const controlsDevice = diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index 5802956e699b7..62a6a0c247a45 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -62,6 +62,7 @@ const fakeExpo = ( const calls: Array<{ operation: string; variables: Record }> = []; let created = 0; let createdName = ""; + const previews: string[] = []; let previewFails = options.failPreview ?? false; let createFails = options.failCreate ?? false; let sessionGated = false; @@ -69,6 +70,7 @@ const fakeExpo = ( Effect.gen(function* () { const url = new URL(request.url); if (url.origin === "https://preview.test") { + previews.push(`${url.pathname}${url.search}`); expect(request.headers.authorization).toBe("Bearer preview-token"); if (options.previewGate) { yield* Deferred.succeed(options.previewGate.requested, undefined); @@ -77,7 +79,17 @@ const fakeExpo = ( if (previewFails) { return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 })); } - return HttpClientResponse.fromWeb(request, Response.json({ device: "UDID-1" })); + if (url.pathname.endsWith("/api/screenshot")) { + return HttpClientResponse.fromWeb(request, new Response(Uint8Array.of(137, 80, 78, 71))); + } + return HttpClientResponse.fromWeb( + request, + Response.json( + url.pathname === "/vendor/serve-emu/api" + ? { serial: "emulator-5554" } + : { device: "UDID-1" }, + ), + ); } const body = request.body._tag === "Uint8Array" @@ -195,7 +207,7 @@ const fakeExpo = ( const allowCreate = () => { createFails = false; }; - return { http, named, stopped, allowPreview, allowCreate }; + return { http, named, stopped, allowPreview, allowCreate, previews }; }; const layer = Layer.mergeAll( @@ -221,7 +233,10 @@ describe("EAS Simulator host", () => { const host = yield* makeHost(expo, config(), scope); const ready = yield* host.ensureReady(() => Effect.void); const catalog = (yield* ready.devices.list).devices; - expect(catalog.map((device) => [device.id, device.booted])).toEqual([["eas-ios", false]]); + expect(catalog.map((device) => [device.id, device.booted])).toEqual([ + ["eas-ios", false], + ["eas-android", false], + ]); expect(yield* ready.devices.boot(catalog[0]!)).toBe("session-1"); expect(expo.named("T3CreateEasSimulatorSession")[0]?.variables.input).toMatchObject({ @@ -266,6 +281,55 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("runs an Android session through serve-emu beside an iOS one", () => + Effect.gen(function* () { + const expo = fakeExpo(); + const scope = yield* Scope.make(); + const host = yield* makeHost(expo, config(), scope); + expect(yield* host.platformAvailability("android")).toEqual({ + platform: "android", + available: true, + }); + const ready = yield* host.ensureReady(() => Effect.void); + const [ios, android] = (yield* ready.devices.list).devices; + expect(yield* ready.devices.boot(ios!)).toBe("session-1"); + expect(yield* ready.devices.boot(android!)).toBe("session-2"); + expect(expo.named("T3CreateEasSimulatorSession")[1]?.variables.input).toMatchObject({ + platform: "ANDROID", + }); + const running = (yield* ready.devices.list).devices.find( + (device) => device.id === "session-2", + ); + expect(running).toMatchObject({ platform: "android", booted: true }); + + expect(yield* ready.devices.agentEndpoint!("session-2")).toMatchObject({ + agentDeviceId: "emulator-5554", + }); + expect(Array.from(yield* ready.devices.screenshot(running!))).toEqual([137, 80, 78, 71]); + expect(expo.previews.slice(-2)).toEqual([ + "/vendor/serve-emu/api", + "/vendor/serve-emu/api/screenshot?device=emulator-5554", + ]); + expect( + yield* ready.upstream!( + "/vendor/serve-emu/webrtc/offer", + new URLSearchParams("device=session-2&hostId=eas"), + ), + ).toEqual({ + url: "https://preview.test/vendor/serve-emu/webrtc/offer?device=emulator-5554&hostId=eas", + headers: { authorization: "Bearer preview-token" }, + protocols: ["serve-emu.token.preview-token"], + }); + // Each session answers only on its own platform's stream. + expect( + yield* ready.upstream!("/vendor/serve-emu/api", new URLSearchParams("device=session-1")), + ).toBeNull(); + expect( + yield* ready.upstream!("/vendor/serve-sim/api", new URLSearchParams("device=session-2")), + ).toBeNull(); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("refuses to start past its session limit", () => Effect.gen(function* () { const expo = fakeExpo(); @@ -292,6 +356,7 @@ describe("EAS Simulator host", () => { expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2"); expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ "eas-ios", + "eas-android", "session-1", "session-2", ]); @@ -306,6 +371,7 @@ describe("EAS Simulator host", () => { expect(expo.stopped()).toEqual(["session-1"]); expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ "eas-ios", + "eas-android", "session-2", ]); yield* Scope.close(scope, Exit.void); @@ -421,7 +487,10 @@ describe("EAS Simulator host", () => { expect(refused.message).toContain("limited to 1 running session"); stopFails = false; yield* ready.devices.list; - expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); + expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ + "eas-ios", + "eas-android", + ]); expect(expo.stopped().filter((id) => id === "session-1").length).toBeGreaterThanOrEqual(2); }).pipe(Effect.scoped, Effect.provide(layer)), ); @@ -438,7 +507,10 @@ describe("EAS Simulator host", () => { expect((yield* ready.devices.list).devices.map((device) => device.id)).toContain("session-1"); // Expo ended it by itself: the stop keeps failing, but the status says so. status = "STOPPED"; - expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); + expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ + "eas-ios", + "eas-android", + ]); status = "IN_PROGRESS"; expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2"); }).pipe(Effect.scoped, Effect.provide(layer)), @@ -488,7 +560,10 @@ describe("EAS Simulator host", () => { expect(expo.stopped()).toEqual(["session-1"]); const failure = yield* Fiber.join(booting).pipe(Effect.flip); expect(failure.message).toContain("was stopped while the session started"); - expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); + expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ + "eas-ios", + "eas-android", + ]); }).pipe(Effect.scoped, Effect.provide(layer)), ); }); diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index da8a665d4b4ed..a1137fe5156a0 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -42,14 +42,48 @@ const STATUS_TTL_MS = 15_000; // Expo's own CLI waits this long once a worker picks the session up. const START_TIMEOUT = Duration.minutes(15); const PREVIEW_TIMEOUT = Duration.seconds(20); -const SERVE_SIM_PREFIX = "/vendor/serve-sim"; +const decodeServeSimApi = Schema.decodeUnknownEffect( + Schema.Struct({ device: Schema.NonEmptyString }), +); +const decodeServeEmuApi = Schema.decodeUnknownEffect( + Schema.Struct({ serial: Schema.NonEmptyString }), +); +/** + * Each session's preview host serves serve-sim at its root and serve-emu under + * its vendor prefix, and names its device by an id of its own that agent-device + * also selects it by. + */ +const PLATFORMS = { + ios: { + name: "iPhone (EAS Simulator)", + hubPrefix: "/vendor/serve-sim", + previewPrefix: "", + protocol: "serve-sim", + remoteDeviceId: (api: unknown) => decodeServeSimApi(api).pipe(Effect.map((api) => api.device)), + }, + android: { + name: "Android (EAS Simulator)", + hubPrefix: "/vendor/serve-emu", + previewPrefix: "/vendor/serve-emu", + protocol: "serve-emu", + remoteDeviceId: (api: unknown) => decodeServeEmuApi(api).pipe(Effect.map((api) => api.serial)), + }, +} as const; /** EAS picks the simulator model; one entry starts a session. */ const CATALOG: ReadonlyArray = [ { id: "eas-ios", platform: "ios", - name: "iPhone (EAS Simulator)", + name: PLATFORMS.ios.name, + version: "Cloud", + booted: false, + physical: false, + }, + { + id: "eas-android", + platform: "android", + name: PLATFORMS.android.name, version: "Cloud", booted: false, physical: false, @@ -59,7 +93,8 @@ const CATALOG: ReadonlyArray = [ interface RunningSession { readonly sessionId: string; readonly name: string; - readonly udid: string; + readonly platform: DevicePlatform; + readonly remoteDeviceId: string; readonly preview: { readonly origin: string; readonly token: Redacted.Redacted }; readonly agent: DeviceHost.AgentDaemonEndpoint; /** Set once a stop has been asked for but Expo has not confirmed it. */ @@ -94,7 +129,6 @@ const RECORD_MAX_AGE_MS = 24 * 60 * 60_000; const UNCERTAIN_CREATE_MS = 5 * 60_000; // One failed request to Expo during a minutes-long start does not end the start. const TRANSIENT_RETRY = { times: 3, schedule: Schedule.spaced("2 seconds") } as const; -const decodePreviewApi = Schema.decodeUnknownEffect(Schema.Struct({ device: Schema.String })); /** A malformed escape names no device, so the proxy answers 404 instead of failing. */ const decodePathSegment = (segment: string): string | null => { @@ -116,7 +150,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice const hostId = config.id; const ledgerPath = path.join(server.stateDir, "device", "eas", `${hostId}.json`); // Keyed by Expo's session id, which is also the device id: every EAS - // simulator reports the same UDID, so the UDID cannot tell sessions apart. + // simulator reports the same remote device id, so that id cannot tell sessions apart. const sessions = new Map(); const checkedAt = new Map(); const startLock = yield* Semaphore.make(1); @@ -348,18 +382,21 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ), ); - const previewDevice = (origin: string, token: Redacted.Redacted) => + const previewDevice = ( + origin: string, + token: Redacted.Redacted, + platform: DevicePlatform, + ) => http .execute( - HttpClientRequest.get(`${origin}/api`).pipe( + HttpClientRequest.get(`${origin}${PLATFORMS[platform].previewPrefix}/api`).pipe( HttpClientRequest.bearerToken(Redacted.value(token)), ), ) .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => response.json), - Effect.flatMap(decodePreviewApi), - Effect.map((api) => api.device), + Effect.flatMap(PLATFORMS[platform].remoteDeviceId), Effect.scoped, Effect.timeout(PREVIEW_TIMEOUT), Effect.retry(TRANSIENT_RETRY), @@ -500,7 +537,9 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ), Effect.mapError((error) => unavailable(error.message, error)), ); - return yield* restore(trackSession(expo, sessionId, name, startedIn)).pipe( + return yield* restore( + trackSession(expo, sessionId, name, device.platform, startedIn), + ).pipe( Effect.onError(() => stopSession(expo, sessionId, name).pipe( // Still running and billed: owned, counted, and retried by listing. @@ -520,6 +559,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice expo: ExpoApi, sessionId: string, name: string, + platform: DevicePlatform, startedIn: number, ) { const endpoints = yield* awaitRunning(expo, sessionId); @@ -532,7 +572,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice }); } const origin = new URL(endpoints.preview.url).origin; - const udid = yield* previewDevice(origin, endpoints.preview.token).pipe( + const remoteDeviceId = yield* previewDevice(origin, endpoints.preview.token, platform).pipe( Effect.mapError( (cause) => new DeviceBootError({ @@ -549,7 +589,8 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice sessions.set(sessionId, { sessionId, name, - udid, + platform, + remoteDeviceId, preview: { origin, token: endpoints.preview.token }, agent: { baseUrl: endpoints.agentDevice.url, @@ -650,8 +691,8 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ...CATALOG, ...[...sessions.values()].map((session): DeviceHost.HostDevice => ({ id: session.sessionId, - platform: "ios", - name: "iPhone (EAS Simulator)", + platform: session.platform, + name: PLATFORMS[session.platform].name, version: "Cloud", booted: true, physical: false, @@ -708,7 +749,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice http .execute( HttpClientRequest.post( - `${session.preview.origin}/api/screenshot?device=${encodeURIComponent(session.udid)}`, + `${session.preview.origin}${PLATFORMS[session.platform].previewPrefix}/api/screenshot?device=${encodeURIComponent(session.remoteDeviceId)}`, ).pipe(HttpClientRequest.bearerToken(Redacted.value(session.preview.token))), ) .pipe( @@ -730,42 +771,45 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice agentEndpoint: (deviceId) => Option.match(sessionFor(deviceId), { onNone: () => Effect.fail(unavailable("This EAS Simulator device is no longer running.")), - // agent-device selects the simulator by its UDID, not the session id T3 shows. - onSome: (session) => Effect.succeed({ ...session.agent, agentDeviceId: session.udid }), + onSome: (session) => + Effect.succeed({ ...session.agent, agentDeviceId: session.remoteDeviceId }), }), endsDevices: true, }; /** - * serve-sim runs at the root of each session's preview host and wants its - * token on every request. Clients name the device by its session id; serve-sim - * knows it by its UDID, so the id is swapped for the UDID in the path and in - * the `device` parameter. + * Clients name the device by its session id; the preview host knows it by its + * own id, so the session id is swapped for that in the path and in the + * `device` parameter. A session answers only on its own platform's server. */ const upstream: NonNullable = (hubPath, search) => Effect.sync(() => { - if (!hubPath.startsWith(`${SERVE_SIM_PREFIX}/`)) return null; + const platform = (["ios", "android"] as const).find((candidate) => + hubPath.startsWith(`${PLATFORMS[candidate].hubPrefix}/`), + ); + if (!platform) return null; const helper = /^\/vendor\/serve-sim\/helper\/([^/]+)\//.exec(hubPath); const deviceId = helper ? decodePathSegment(helper[1]!) : search.get("device"); if (helper && deviceId === null) return null; const session = - deviceId !== null && deviceId !== undefined + deviceId !== null ? sessions.get(deviceId) : sessions.size === 1 ? [...sessions.values()][0] : undefined; - if (!session) return null; + if (session?.platform !== platform) return null; + const { hubPrefix, previewPrefix, protocol } = PLATFORMS[platform]; const path = helper - ? `/helper/${encodeURIComponent(session.udid)}${hubPath.slice(helper[0].length - 1)}` - : hubPath.slice(SERVE_SIM_PREFIX.length); + ? `/helper/${encodeURIComponent(session.remoteDeviceId)}${hubPath.slice(helper[0].length - 1)}` + : `${previewPrefix}${hubPath.slice(hubPrefix.length)}`; const params = new URLSearchParams(search); - if (params.has("device")) params.set("device", session.udid); + if (params.has("device")) params.set("device", session.remoteDeviceId); const query = params.size > 0 ? `?${params.toString()}` : ""; const token = Redacted.value(session.preview.token); return { url: `${session.preview.origin}${path}${query}`, headers: { authorization: `Bearer ${token}` }, - protocols: [`serve-sim.token.${token}`], + protocols: [`${protocol}.token.${token}`], }; }); @@ -833,19 +877,13 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice // Each server signs in on its own, so one with no Expo credential answers for itself. Reading // the credential is local; whether Expo accepts it is only known once a session starts. const platformAvailability = (platform: DevicePlatform) => - platform === "ios" - ? resolveExpoCredential(config.accessToken).pipe( - Effect.provide(services), - Effect.as({ platform, available: true }), - Effect.catch((error) => - Effect.succeed({ platform, available: false, reason: error.message }), - ), - ) - : Effect.succeed({ - platform, - available: false, - reason: "EAS Simulator Android emulators are not supported yet.", - }); + resolveExpoCredential(config.accessToken).pipe( + Effect.provide(services), + Effect.as({ platform, available: true }), + Effect.catch((error) => + Effect.succeed({ platform, available: false, reason: error.message }), + ), + ); const summary: Effect.Effect = Effect.all([ platformAvailability("ios"), diff --git a/apps/web/src/components/device/DeviceControlsRail.tsx b/apps/web/src/components/device/DeviceControlsRail.tsx index 3ed2b3a4fa544..740c342b2cf9f 100644 --- a/apps/web/src/components/device/DeviceControlsRail.tsx +++ b/apps/web/src/components/device/DeviceControlsRail.tsx @@ -1,4 +1,4 @@ -import type { DevicePlatform } from "@t3tools/contracts"; +import type { DeviceHostSummary, DevicePlatform } from "@t3tools/contracts"; import { Camera, ChevronLeft, @@ -36,6 +36,7 @@ import type { DeviceControls } from "./useDeviceControls"; export function DeviceControlsRail(props: { platform: DevicePlatform; + hostKind: DeviceHostSummary["kind"] | undefined; handle: DeviceStreamHandle | null; view: DeviceViewControls; controls: DeviceControls; @@ -46,13 +47,12 @@ export function DeviceControlsRail(props: { onFloat: () => void; onClose: () => void; onPowerOff: () => void; - /** The host takes input but runs no device commands (Simlock): no rotation, settings, tools, or screenshots. */ - inputOnly?: boolean; }) { const { view, handle, controls } = props; const popupSide = "left"; const settings = controls.detail?.settings; const inputDisabled = !handle?.inputConnected; + const runsHostCommands = props.hostKind === "local" || props.hostKind === "ssh"; const nextAppearance = settings?.appearance === "dark" ? "light" : "dark"; return (