diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml new file mode 100644 index 0000000000000..e7615d46011bd --- /dev/null +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -0,0 +1,145 @@ +name: Mobile EAS Preview (Fork) + +# EAS previews for fork PRs. This runs the PR's code with an Expo token: the +# app config and plugins run on this runner, and native builds run on EAS +# with preview signing credentials. The label is the maintainer's sign-off +# that they reviewed that code, so it covers exactly the commit it was applied +# to: this pins that commit and consumes the label before running anything. A +# later push deploys nothing until the label is applied again. +# +# Same-repo PRs deploy on every push while labeled, via mobile-eas-preview.yml. + +on: + pull_request_target: + types: [labeled] + +permissions: + contents: read + +jobs: + preview: + name: EAS Preview + if: >- + github.event.pull_request.head.repo.full_name != github.repository && + github.event.label.name == '🚀 Mobile Continuous Deployment' + runs-on: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-fork-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + steps: + # If a maintainer removed the label first, that removal wins. + - id: consume + name: Consume the deployment label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + name: context.payload.label.name, + }); + core.setOutput("consumed", "true"); + } catch (error) { + if (error.status !== 404) throw error; + core.info("The label was removed before this run could consume it. Skipping."); + core.setOutput("consumed", "false"); + } + + - id: expo-token + name: Check for EXPO_PREVIEW_TOKEN + if: steps.consume.outputs.consumed == 'true' + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_PREVIEW_TOKEN is not set; skipping EAS preview." + fi + + # The head SHA from the label event, not the branch, so a push after the + # label cannot swap in unreviewed code. No credentials stay in .git for + # the PR's scripts to read. + - name: Checkout reviewed commit + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + # No sparse-checkout: see mobile-eas-preview.yml. + + # No caches: pull_request_target saves them under main's scope, where + # trusted workflows with other secrets would restore what PR code wrote. + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@v8 + with: + # Pinned and installed with npm for the same reasons as mobile-eas-preview.yml. + eas-version: 21.8.0 + eas-cache: false + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas env:pull preview --non-interactive + # See mobile-eas-preview.yml: keeps Android build and OTA fingerprints matching. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + - name: Deploy with fingerprint check + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + RUNNER_DEBUG: "1" + # The action only comments for pull_request events; this hook gives + # it the PR under pull_request_target. Without it, it skips the comment. + EXPO_TEST_GITHUB_PULL: ${{ github.event.pull_request.number }} + with: + profile: preview:dev + branch: pr-${{ github.event.pull_request.number }} + platform: all + environment: preview + working-directory: apps/mobile + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 107c741424b08..08183d0385811 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -7,7 +7,9 @@ on: jobs: preview: name: EAS Preview + # Fork PRs deploy one reviewed commit per label through mobile-eas-preview-fork.yml. if: | + github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -23,15 +25,15 @@ jobs: MOBILE_VERSION_POLICY: fingerprint steps: - id: expo-token - name: Check for EXPO_TOKEN + name: Check for EXPO_PREVIEW_TOKEN env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} run: | if [ -n "$EXPO_TOKEN" ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS preview." + echo "EXPO_PREVIEW_TOKEN is not available; skipping EAS preview." fi - name: Checkout @@ -70,7 +72,7 @@ jobs: # continuous-deploy-fingerprint still reads `build.project.ownerAccount`. # Unpin once https://github.com/expo/expo-github-action/issues/358 is fixed. eas-version: 21.8.0 - token: ${{ secrets.EXPO_TOKEN }} + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} # npm, not pnpm: this only installs eas-cli into the action's own # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's # ignored build script (no allowBuilds config outside the repo). @@ -80,7 +82,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} run: | eas env:pull preview --non-interactive # EAS Update disables dotenv loading. Keep the downloaded native config @@ -99,7 +101,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' uses: expo/expo-github-action/continuous-deploy-fingerprint@main env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} # The Expo action otherwise suppresses the EAS command's failure output. RUNNER_DEBUG: "1" with: diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index b2c03c04ab11c..507e013bf1c2a 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -97,13 +97,13 @@ jobs: - id: expo-token name: Check for EXPO_TOKEN env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | if [ -n "$EXPO_TOKEN" ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS production job." + echo "EXPO_PRODUCTION_TOKEN is not available; skipping EAS production job." fi - id: version_app_token @@ -149,7 +149,7 @@ jobs: uses: expo/expo-github-action@v8 with: eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} + token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} # npm, not pnpm: this only installs eas-cli into the action's own # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's # ignored build script (no allowBuilds config outside the repo). @@ -159,7 +159,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | eas env:pull production --non-interactive # EAS commands skip dotenv, and the Android fingerprint hashes this @@ -244,7 +244,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait @@ -255,7 +255,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update') working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} PLATFORM: ${{ inputs.platform || 'all' }} run: | if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi @@ -275,7 +275,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} OTA_MESSAGE: ${{ inputs.message || format('Production OTA ({0})', github.sha) }} run: | @@ -299,7 +299,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | failed=0 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" 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/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/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index da127a29ef9bf..33a57859ff8b3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1596,6 +1596,16 @@ function renderFeedEntry( const visibleAttachments = attachments.filter( (attachment) => isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id), ); + // Images sent without words show as images, not as a bubble around them. + const mediaOnly = + foldRowId === null && + presentation.attribution !== "guest" && + presentation.text.trim() === "" && + visibleAttachments.length + (entry.pendingMessage?.attachments.length ?? 0) > 0 && + visibleAttachments.every(isImageAttachment) && + (entry.pendingMessage?.attachments ?? []).every( + (attachment) => attachment.type === "image", + ); return ( 0) ? { width: props.duoColumns ? "85%" : props.userBubbleMaxWidth } : null), }} @@ -1640,7 +1650,13 @@ function renderFeedEntry( /> ) : null} {bubbleBodyVisible ? ( - + {entry.pendingMessage?.attachments.map((attachment) => attachment.type === "image" && attachment.uploadedAttachmentId ? ( ) : attachment.type === "image" ? ( @@ -1668,7 +1688,7 @@ function renderFeedEntry( > ) : ( @@ -1689,7 +1709,10 @@ function renderFeedEntry( className={ inlineAttachmentIds.size ? "h-24 w-24 rounded-[14px] bg-user-bubble-foreground/15" - : "aspect-[1.3] w-full rounded-[14px] bg-user-bubble-foreground/15" + : mediaOnly + ? // Matches the bubble's corners; native borders sit inside, so the edge adds no size. + "aspect-[1.3] w-full rounded-[20px] border border-border bg-user-bubble-foreground/15" + : "aspect-[1.3] w-full rounded-[14px] bg-user-bubble-foreground/15" } onPressPreview={props.onPressPreview} /> @@ -1707,7 +1730,7 @@ function renderFeedEntry( })} ) : null} - {message.text.trim().length > 0 ? ( + {presentation.text.trim().length > 0 ? ( diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts index 444d969176ba2..a62e29e14795d 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; export const REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL = "Remove queued message"; @@ -35,7 +36,9 @@ export function resolveThreadQueueRowControls(input: { canSend: mutationEnabled && (input.canPromoteToSteer || input.canSendNow) && !isEditing, dismissAccessibilityLabel: REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL, // Editing and sending keep the original text so quoted citations stay linked. - displayText: assistantCitationsToPlainText(input.text), + displayText: isAttachmentOnlyBootstrapPrompt(input.text) + ? "" + : assistantCitationsToPlainText(input.text), isEditing, }; } 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 : ( 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/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 033a96693ada9..2ea799d9f09e4 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -90,6 +90,37 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); + it.effect("protect refuses root before creating any T3 directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const baseDir = `${parent}/t3`; + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, makeTestService(status).service), + ); + + const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "protect", + "--base-dir", + baseDir, + ]).pipe( + Effect.andThen(TestConsole.logLines), + Effect.map((lines) => lines.join("\n")), + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.UserId, 0), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + ); + expect(output).toContain("without sudo"); + expect(yield* fs.exists(baseDir)).toBe(false); + }), + ); + // regression: #13239 a stale nvm-installed CLI reported "already using" its own version it.effect.each(["install", "update"] as const)( "%s names the CLI that found nothing to change", diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 97ceafd941f1e..62edb31d14b62 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -2,12 +2,14 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient } from "effect/http"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; +import * as ServiceProtection from "../cloud/serviceProtection.ts"; import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import type * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -68,7 +70,23 @@ function formatServiceChange( return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`; } -function formatServiceStatus(status: BootService.BootServiceStatus, cliVersion: string): string { +function formatProtection( + protection: ServiceProtection.ServiceProtectionState | undefined, +): ReadonlyArray { + if (protection === undefined) return []; + return [ + protection.unprotected === undefined + ? ` Memory protection: on${protection.setByProtect ? " (t3 service protect)" : ""}` + : ` Memory protection: off, ${protection.unprotected} grants none. Run \`t3 service protect\`.`, + ...(protection.oomScoreAdj === undefined ? [] : [` OOM score: ${protection.oomScoreAdj}`]), + ]; +} + +function formatServiceStatus( + status: BootService.BootServiceStatus, + cliVersion: string, + protection?: ServiceProtection.ServiceProtectionState, +): string { if (!status.supported) { return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd"; } @@ -89,6 +107,7 @@ function formatServiceStatus(status: BootService.BootServiceStatus, cliVersion: ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, + ...formatProtection(protection), ...problems, ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`, ].join("\n"); @@ -98,6 +117,7 @@ function formatServiceStatus(status: BootService.BootServiceStatus, cliVersion: ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, + ...formatProtection(protection), ...problems, ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), ].join("\n"); @@ -210,12 +230,121 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( flags, Effect.gen(function* () { const service = yield* BootService.BootService; - yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + const path = yield* Path.Path; + const status = yield* service.status; + const protection = + status.installed && (yield* HostProcess.Platform) === "linux" + ? yield* ServiceProtection.readProtection(path.dirname(status.unitPath)).pipe( + Effect.provide(ProcessRunner.layer), + ) + : undefined; + yield* Console.log(formatServiceStatus(status, packageJson.version, protection)); }), ), ), ); +const formatDropIns = (dropIns: ReadonlyArray) => + dropIns.map( + ({ path, contents }) => ` ${path}: ${contents.trimEnd().split("\n").slice(1).join(" ")}`, + ); + +const confirm = (yes: boolean, message: string) => + yes ? Effect.succeed(true) : Prompt.run(Prompt.Confirm({ message, initial: false })); + +const serviceProtectCommand = Command.make("protect", { + ...projectLocationFlags, + undo: Flag.Boolean("undo").pipe( + Flag.withDescription("Remove the protection this command set up."), + Flag.withDefault(false), + ), + yes: Flag.Boolean("yes").pipe( + Flag.withDescription("Answer yes to every question, including SSH and Tailscale."), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription( + "Protect the T3 Code server from agents under memory pressure (Linux with systemd). Asks for sudo.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const unsupported = "Service protection needs Linux with systemd."; + if ((yield* HostProcess.Platform) !== "linux") return yield* Console.log(unsupported); + // Before resolving config, which creates T3 directories that root would then own. + if ((yield* HostProcess.UserId) === 0) { + return yield* Console.log( + "Run `t3 service protect` as the user that runs T3 Code, without sudo. It asks for sudo when it needs root.", + ); + } + return yield* runServiceCommand( + flags, + Effect.gen(function* () { + const uid = yield* HostProcess.UserId; + const remote = yield* ServiceProtection.detectRemoteAccessUnits; + const { unitPath } = yield* (yield* BootService.BootService).status; + if (uid === undefined || remote === undefined || unitPath === "") { + return yield* Console.log(unsupported); + } + const userUnitDir = (yield* Path.Path).dirname(unitPath); + + if (flags.undo) { + const installed = yield* ServiceProtection.installedProtection( + ServiceProtection.protectionPlan({ + uid, + userUnitDir, + remoteAccessUnits: ServiceProtection.REMOTE_ACCESS_UNITS, + }), + ); + if (installed === undefined) { + return yield* Console.log("T3 Code service protection is not set up."); + } + yield* Console.log( + [ + "Removes:", + ...[...installed.system, ...installed.user, installed.managerDefaults].map( + ({ path, shared }) => + ` ${path}${shared === true ? " (kept while another user is protected)" : ""}`, + ), + ].join("\n"), + ); + if (!(yield* confirm(flags.yes, "Remove T3 Code service protection?"))) return; + yield* ServiceProtection.removeProtection(installed); + return yield* Console.log( + "Removed T3 Code service protection. The server's OOM score returns to normal after `t3 service restart`.", + ); + } + + const names = remote.map((unit) => unit.replace(/\.service$/, "")).join(" and "); + const protectRemote = + remote.length > 0 && + (yield* confirm( + flags.yes, + `Also protect ${names} so you can still reach this machine under memory pressure?`, + )); + const plan = ServiceProtection.protectionPlan({ + uid, + userUnitDir, + remoteAccessUnits: protectRemote ? remote : [], + }); + yield* Console.log( + [ + "As root, through sudo:", + ...formatDropIns(plan.system), + "As you:", + ...formatDropIns([plan.managerDefaults, ...plan.user]), + ].join("\n"), + ); + if (!(yield* confirm(flags.yes, "Apply these settings?"))) return; + yield* ServiceProtection.applyProtection(plan); + yield* Console.log( + "Protected T3 Code. Memory protection applies now; the server takes its OOM score after `t3 service restart`. Undo with `t3 service protect --undo`.", + ); + }).pipe(Effect.provide(ProcessRunner.layer)), + ); + }), + ), +); + export type ServiceOnboardingOutcome = "ready" | "pending" | "skipped"; export const offerServiceDuringOnboarding = Effect.gen(function* () { @@ -305,6 +434,7 @@ export const serviceCommand = Command.make("service").pipe( serviceRestartCommand, serviceUninstallCommand, serviceStatusCommand, + serviceProtectCommand, serviceUpdateCommand, ]), ); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index fca51dcac1e09..2edd110648013 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -48,6 +48,25 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); }); +it("protects the server unit from agents under CPU and memory pressure", () => { + const unit = BootService.renderBootServiceUnit({ + program: ["/home/theo/.t3/runtime/versions/1.2.3/t3", "__service-launcher"], + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + const service = unit.slice(unit.indexOf("[Service]"), unit.indexOf("[Install]")); + for (const line of [ + "CPUWeight=1000", + "IOWeight=1000", + "MemoryLow=1536M", + "OOMScoreAdjust=100", + "OOMPolicy=continue", + ]) { + expect(service.split("\n")).toContain(line); + } +}); + it.each([ ["a unit", '/home/theo/T3 "Data"/100%\\x', BootService.renderBootServiceUnit], [ diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 15b3b16a7bc0f..948d780c867cc 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -110,9 +110,15 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "OOMPolicy=continue", "Restart=always", "RestartSec=5", - // Weights favor the server over the sibling agents slice during CPU or disk saturation. + // Weights favor the server over the sibling agents slice; IOWeight needs a delegated io controller. "CPUWeight=1000", "IOWeight=1000", + // Reclaim takes the agents' unprotected pages first. The kernel caps this at the + // protection of user.slice and user@.service, which only root can grant. + "MemoryLow=1536M", + // A user manager's own score, the lowest it may set: below its other services (200) and agents (800). + // `t3 service protect` lowers it further through a drop-in. + "OOMScoreAdjust=100", // systemd-oomd ignores this preference when the watched cgroup is root-owned, as user-1000.slice usually is. "ManagedOOMPreference=avoid", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, diff --git a/apps/server/src/cloud/serviceProtection.test.ts b/apps/server/src/cloud/serviceProtection.test.ts new file mode 100644 index 0000000000000..b2e4a1506cb31 --- /dev/null +++ b/apps/server/src/cloud/serviceProtection.test.ts @@ -0,0 +1,265 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as ServiceProtection from "./serviceProtection.ts"; + +const temporaryDirectory = () => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-service-protection-")); + +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("renderPrivilegedScript", () => { + it("writes and removes only its drop-ins and gives running main processes their score", () => { + const root = temporaryDirectory(); + const target = NodeChildProcess.spawn("sleep", ["60"], { stdio: "ignore" }); + try { + const systemUnitDir = NodePath.join(root, "system"); + const plan = (uid: number, remoteAccessUnits: ReadonlyArray) => + ServiceProtection.protectionPlan({ + uid, + userUnitDir: "/home/theo/.config/systemd/user", + remoteAccessUnits, + systemUnitDir, + }); + const theo = plan(1000, ["tailscaled.service", "ssh.service"]); + expect(theo.system.find((dropIn) => dropIn.unit === "user@1000.service")?.contents).toBe( + "[Service]\nMemoryLow=2G\nOOMScoreAdjust=-900\n", + ); + expect(theo.managerDefaults.path).toBe( + "/home/theo/.config/systemd/user.conf.d/90-t3code-protect.conf", + ); + expect(theo.oomUnits).toEqual(["user@1000.service", "tailscaled.service", "ssh.service"]); + + const unrelated = NodePath.join(systemUnitDir, "ssh.service.d", "50-admin.conf"); + NodeFS.mkdirSync(NodePath.dirname(unrelated), { recursive: true }); + NodeFS.writeFileSync(unrelated, "[Service]\nNice=1\n"); + const bin = NodePath.join(root, "bin"); + NodeFS.mkdirSync(bin); + // Raising a score needs no privilege, so the test's own child stands in for a main process. + NodeFS.writeFileSync( + NodePath.join(bin, "systemctl"), + [ + "#!/bin/sh", + 'echo "$*" >>"$CALLS"', + 'case "$*" in', + ' *MainPID*) echo "$TARGET_PID" ;;', + " *OOMScoreAdjust*) echo 900 ;;", + "esac", + "", + ].join("\n"), + { mode: 0o755 }, + ); + const calls = NodePath.join(root, "calls"); + const runScript = (protection: ServiceProtection.ProtectionPlan, mode: "apply" | "undo") => { + const result = NodeChildProcess.spawnSync( + "/bin/sh", + ["-c", ServiceProtection.renderPrivilegedScript(protection, mode)], + { + encoding: "utf8", + env: { + PATH: `${bin}:/usr/bin:/bin`, + CALLS: calls, + TARGET_PID: String(target.pid), + }, + }, + ); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + }; + const exists = (unit: string) => + NodeFS.existsSync(NodePath.join(systemUnitDir, `${unit}.d`, "90-t3code-protect.conf")); + + runScript(theo, "apply"); + runScript(plan(1001, []), "apply"); + for (const { path, contents } of theo.system) { + expect(NodeFS.readFileSync(path, "utf8")).toBe(contents); + } + expect(NodeFS.readFileSync(`/proc/${target.pid}/oom_score_adj`, "utf8").trim()).toBe("900"); + expect(NodeFS.readFileSync(calls, "utf8").split("\n")[0]).toBe("daemon-reload"); + + // Undo removes every drop-in a user could own, and keeps shared ones while another user is protected. + const undo = (uid: number) => plan(uid, ServiceProtection.REMOTE_ACCESS_UNITS); + runScript(undo(1000), "undo"); + expect(["user-1000.slice", "user@1000.service"].some(exists)).toBe(false); + expect( + ["user.slice", "system.slice", "tailscaled.service", "ssh.service"].every(exists), + ).toBe(true); + + runScript(undo(1001), "undo"); + for (const { path } of undo(1001).system) expect(NodeFS.existsSync(path)).toBe(false); + expect(NodeFS.existsSync(NodePath.join(systemUnitDir, "user.slice.d"))).toBe(false); + expect(NodeFS.readFileSync(unrelated, "utf8")).toBe("[Service]\nNice=1\n"); + } finally { + target.kill(); + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +it.each([ + [ + "Debian, where sshd.service aliases ssh.service", + "Id=tailscaled.service\nLoadState=loaded\n\nId=ssh.service\nLoadState=loaded\n\nId=ssh.service\nLoadState=loaded\n", + ["tailscaled.service", "ssh.service"], + ], + [ + "Fedora without Tailscale", + "Id=tailscaled.service\nLoadState=not-found\n\nId=ssh.service\nLoadState=not-found\n\nId=sshd.service\nLoadState=loaded\n", + ["sshd.service"], + ], +])("finds remote-access services on %s", (_, shown, expected) => { + expect(ServiceProtection.loadedRemoteAccessUnits(shown)).toEqual(expected); +}); + +const fakeSudo = (exitCode: number, onRun: () => void) => + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand" || command.command !== "sudo") { + return yield* Effect.die("Unexpected command"); + } + onRun(); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ); + +const runner = (stdout: string) => + ProcessRunner.ProcessRunner.of({ + run: () => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + +it.effect("lowers no score before the manager default is in place, and undoes in reverse", () => { + const home = temporaryDirectory(); + return Effect.gen(function* () { + const plan = ServiceProtection.protectionPlan({ + uid: 1000, + userUnitDir: NodePath.join(home, ".config", "systemd", "user"), + remoteAccessUnits: [], + systemUnitDir: NodePath.join(home, "system"), + }); + const managerDropIn = plan.system.find(({ unit }) => unit === "user@1000.service")!.path; + const present = () => ({ + managerDefaults: NodeFS.existsSync(plan.managerDefaults.path), + user: plan.user.filter(({ path }) => NodeFS.existsSync(path)).length, + }); + const withSudo = ( + effect: Effect.Effect, + exitCode: number, + writesManagerDropIn = false, + ) => { + let atSudo: ReturnType | undefined; + return effect.pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + fakeSudo(exitCode, () => { + atSudo = present(); + if (writesManagerDropIn) { + NodeFS.mkdirSync(NodePath.dirname(managerDropIn), { recursive: true }); + NodeFS.writeFileSync(managerDropIn, "[Service]\nOOMScoreAdjust=-900\n"); + } + }), + ), + Effect.exit, + Effect.map((exit) => ({ exit, atSudo })), + ); + }; + const refused = yield* withSudo(ServiceProtection.applyProtection(plan), 1); + expect(refused.exit._tag).toBe("Failure"); + expect(refused.atSudo).toEqual({ managerDefaults: true, user: 0 }); + expect(present()).toEqual({ managerDefaults: false, user: 0 }); + + // Failing after it lowered the manager, sudo must leave the manager default in place. + const partial = yield* withSudo(ServiceProtection.applyProtection(plan), 1, true); + expect(partial.exit._tag).toBe("Failure"); + expect(present()).toEqual({ managerDefaults: true, user: 0 }); + NodeFS.rmSync(plan.managerDefaults.path); + NodeFS.rmSync(managerDropIn); + + const applied = yield* withSudo(ServiceProtection.applyProtection(plan), 0); + expect(applied.atSudo).toEqual({ managerDefaults: true, user: 0 }); + expect(present()).toEqual({ managerDefaults: true, user: plan.user.length }); + + const removed = yield* withSudo(ServiceProtection.removeProtection(plan), 0); + expect(removed.atSudo).toEqual({ managerDefaults: true, user: 0 }); + expect(present()).toEqual({ managerDefaults: false, user: 0 }); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(home, { recursive: true, force: true }))), + Effect.provideService(ProcessRunner.ProcessRunner, runner("")), + Effect.provide(NodeServices.layer), + ); +}); + +it.effect("counts memory.min as protection and names the first cgroup that grants none", () => { + const root = temporaryDirectory(); + const controlGroup = [ + "user.slice", + "user-1000.slice", + "user@1000.service", + "app.slice", + "t3code.service", + ]; + const write = (depth: number, file: string, value: string) => { + const directory = NodePath.join(root, ...controlGroup.slice(0, depth)); + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(directory, file), `${value}\n`); + }; + write(1, "memory.low", "0"); + write(1, "memory.min", "805306368"); + write(2, "memory.low", "2147483648"); + write(3, "memory.low", "2147483648"); + write(4, "memory.low", "0"); + write(4, "memory.min", "0"); + write(5, "memory.low", "1610612736"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const read = ServiceProtection.readProtection("/home/theo/.config/systemd/user").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readFileString: (path, encoding) => + fs.readFileString(path.replace("/sys/fs/cgroup", root), encoding), + }), + ); + expect((yield* read)?.unprotected).toBe("app.slice"); + write(4, "memory.low", "1610612736"); + expect((yield* read)?.unprotected).toBeUndefined(); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true }))), + Effect.provideService( + ProcessRunner.ProcessRunner, + runner(`ControlGroup=/${controlGroup.join("/")}\nMainPID=0\n`), + ), + Effect.provide(NodeServices.layer), + ); +}); diff --git a/apps/server/src/cloud/serviceProtection.ts b/apps/server/src/cloud/serviceProtection.ts new file mode 100644 index 0000000000000..b23cd36e3a545 --- /dev/null +++ b/apps/server/src/cloud/serviceProtection.ts @@ -0,0 +1,342 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { BOOT_SERVICE_UNIT_FILE } from "./bootService.ts"; + +const PROTECT_DROP_IN = "90-t3code-protect.conf"; +const SYSTEM_UNIT_DIR = "/etc/systemd/system"; +export const REMOTE_ACCESS_UNITS = ["tailscaled.service", "ssh.service", "sshd.service"]; + +// Covers app.slice (the server's 1536M) and init.scope (the user manager's 256M). +const USER_CHAIN_LOW = "MemoryLow=2G"; +// Near the floor, but unlike -1000 the kernel can still kill it as a last resort. +const PROTECTED_OOM_SCORE = "OOMScoreAdjust=-900"; + +export interface DropIn { + readonly unit: string; + readonly path: string; + readonly contents: string; + /** Host-wide, so undo keeps it while another user's protection remains. */ + readonly shared?: boolean; +} + +export interface ProtectionPlan { + readonly systemUnitDir: string; + /** Written through sudo. */ + readonly system: ReadonlyArray; + /** + * Written before the manager's own score drops. Unset, a user manager hands + * its services its own score plus 100: -800 once it runs at -900. + */ + readonly managerDefaults: DropIn; + readonly user: ReadonlyArray; + /** System units whose running main process takes its configured OOM score at once. */ + readonly oomUnits: ReadonlyArray; +} + +const dropIn = ( + directory: string, + unit: string, + section: string, + properties: ReadonlyArray, + shared = false, +): DropIn => ({ + unit, + path: `${directory}/${unit}.d/${PROTECT_DROP_IN}`, + contents: [`[${section}]`, ...properties, ""].join("\n"), + ...(shared ? { shared } : {}), +}); + +/** + * Memory protection only reaches the server when every ancestor cgroup grants + * it, and only root can grant it on user.slice and the user manager. + */ +export function protectionPlan(input: { + readonly uid: number; + readonly userUnitDir: string; + readonly remoteAccessUnits: ReadonlyArray; + readonly systemUnitDir?: string; +}): ProtectionPlan { + const manager = `user@${input.uid}.service`; + const remote = input.remoteAccessUnits; + const systemUnitDir = input.systemUnitDir ?? SYSTEM_UNIT_DIR; + const system = (unit: string, section: string, properties: ReadonlyArray) => + dropIn(systemUnitDir, unit, section, properties); + const shared = (unit: string, section: string, properties: ReadonlyArray) => + dropIn(systemUnitDir, unit, section, properties, true); + const user = (unit: string, section: string, properties: ReadonlyArray) => + dropIn(input.userUnitDir, unit, section, properties); + return { + systemUnitDir, + system: [ + shared("user.slice", "Slice", [USER_CHAIN_LOW]), + system(`user-${input.uid}.slice`, "Slice", [USER_CHAIN_LOW]), + system(manager, "Service", [USER_CHAIN_LOW, PROTECTED_OOM_SCORE]), + ...(remote.length > 0 ? [shared("system.slice", "Slice", ["MemoryLow=512M"])] : []), + ...remote.map((unit) => shared(unit, "Service", ["MemoryLow=256M", PROTECTED_OOM_SCORE])), + ], + managerDefaults: { + unit: "user.conf", + path: `${input.userUnitDir.replace(/\/user$/, "")}/user.conf.d/${PROTECT_DROP_IN}`, + contents: "[Manager]\nDefaultOOMScoreAdjust=200\n", + }, + user: [ + user("app.slice", "Slice", ["MemoryLow=1536M"]), + user("init.scope", "Scope", ["MemoryLow=256M"]), + user(BOOT_SERVICE_UNIT_FILE, "Service", [PROTECTED_OOM_SCORE]), + ], + oomUnits: [manager, ...remote], + }; +} + +const quoteShell = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; +const directoryOf = (file: string) => file.slice(0, file.lastIndexOf("/")); + +export function renderPrivilegedScript(plan: ProtectionPlan, mode: "apply" | "undo"): string { + const lines = ["set -e"]; + const remove = (dropIns: ReadonlyArray, indent = "") => { + for (const { path } of dropIns) { + lines.push( + `${indent}rm -f ${quoteShell(path)}`, + `${indent}rmdir ${quoteShell(directoryOf(path))} 2>/dev/null || true`, + ); + } + }; + if (mode === "apply") { + for (const { path, contents } of plan.system) { + lines.push( + `mkdir -p ${quoteShell(directoryOf(path))}`, + `cat >${quoteShell(path)} <<'T3CODE_PROTECT'`, + contents.trimEnd(), + "T3CODE_PROTECT", + ); + } + } else { + remove(plan.system.filter((dropIn) => dropIn.shared !== true)); + const otherUsers = `${quoteShell(plan.systemUnitDir)}/user@*.service.d/${PROTECT_DROP_IN}`; + lines.push(`set -- ${otherUsers}`, 'if [ ! -e "$1" ]; then'); + remove( + plan.system.filter((dropIn) => dropIn.shared === true), + " ", + ); + lines.push("fi"); + } + lines.push( + "systemctl daemon-reload", + // OOMScoreAdjust applies at exec; this gives running main processes the configured score. + `for unit in ${plan.oomUnits.map(quoteShell).join(" ")}; do`, + ' pid=$(systemctl show --property=MainPID --value "$unit")', + ' if [ "${pid:-0}" -gt 0 ]; then', + ' systemctl show --property=OOMScoreAdjust --value "$unit" >"/proc/$pid/oom_score_adj" || true', + " fi", + "done", + "", + ); + return lines.join("\n"); +} + +export class ServiceProtectionError extends Schema.TaggedError()( + "ServiceProtectionError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Could not ${this.step}.` + : `Could not ${this.step} (exit code ${this.exitCode}).`; + } +} + +function parseUnits(text: string): Array> { + return text + .split(/\n\s*\n/) + .map( + (block) => + new Map( + block + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]), + ), + ) + .filter((values) => values.size > 0); +} + +/** Loaded remote-access units by canonical name, so an alias such as sshd.service counts once. */ +export function loadedRemoteAccessUnits(systemctlShow: string): ReadonlyArray { + const units = parseUnits(systemctlShow) + .filter((values) => values.get("LoadState") === "loaded") + .map((values) => values.get("Id") ?? "") + .filter((id) => REMOTE_ACCESS_UNITS.includes(id)); + return [...new Set(units)]; +} + +const run = Effect.fn("ServiceProtection.run")(function* ( + step: string, + command: string, + args: ReadonlyArray, +) { + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner + .run({ command, args, timeout: "30 seconds" }) + .pipe(Effect.mapError((cause) => new ServiceProtectionError({ step, cause }))); + if (result.code !== 0) { + return yield* new ServiceProtectionError({ step, exitCode: Number(result.code) }); + } + return result.stdout; +}); + +const userSettingsError = (cause: unknown) => + new ServiceProtectionError({ step: "change systemd user settings", cause }); + +/** Undefined when there is no systemd system manager to ask. */ +export const detectRemoteAccessUnits = run("list remote-access services", "systemctl", [ + "show", + "--property=Id,LoadState", + ...REMOTE_ACCESS_UNITS, +]).pipe( + Effect.map(loadedRemoteAccessUnits), + Effect.option, + Effect.map(Option.getOrUndefined), + Effect.withSpan("ServiceProtection.detectRemoteAccessUnits"), +); + +const reloadUserManager = run("reload systemd user units", "systemctl", [ + "--user", + "daemon-reload", +]); + +const writeDropIns = Effect.fn("ServiceProtection.writeDropIns")(function* ( + dropIns: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + for (const { path, contents } of dropIns) { + yield* fs.makeDirectory(directoryOf(path), { recursive: true }); + yield* fs.writeFileString(path, contents); + } +}, Effect.mapError(userSettingsError)); + +const removeDropIns = Effect.fn("ServiceProtection.removeDropIns")(function* ( + dropIns: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + for (const { path } of dropIns) { + yield* fs.remove(path, { force: true }); + yield* fs.remove(directoryOf(path)).pipe(Effect.ignore); + } +}, Effect.mapError(userSettingsError)); + +const runPrivileged = Effect.fn("ServiceProtection.runPrivileged")(function* (script: string) { + const step = "change the system's service settings"; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const exitCode = yield* spawner + .exitCode( + ChildProcess.make("sudo", ["/bin/sh", "-c", script], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }), + ) + .pipe(Effect.mapError((cause) => new ServiceProtectionError({ step, cause }))); + if (exitCode !== 0) { + return yield* new ServiceProtectionError({ step, exitCode: Number(exitCode) }); + } +}); + +export const applyProtection = Effect.fn("ServiceProtection.apply")(function* ( + plan: ProtectionPlan, +) { + const fs = yield* FileSystem.FileSystem; + const hadManagerDefaults = yield* fs + .exists(plan.managerDefaults.path) + .pipe(Effect.orElseSucceed(() => false)); + yield* writeDropIns([plan.managerDefaults]); + // A partial run may already have lowered the manager; its default must then stay. + const manager = plan.system.find(({ unit }) => unit.startsWith("user@")); + const rollBack = Effect.gen(function* () { + if (hadManagerDefaults) return; + if (manager !== undefined && (yield* fs.exists(manager.path))) return; + yield* removeDropIns([plan.managerDefaults]); + }).pipe(Effect.ignore); + yield* runPrivileged(renderPrivilegedScript(plan, "apply")).pipe(Effect.tapError(() => rollBack)); + yield* writeDropIns(plan.user); + yield* reloadUserManager; +}); + +export const removeProtection = Effect.fn("ServiceProtection.remove")(function* ( + plan: ProtectionPlan, +) { + yield* removeDropIns(plan.user); + yield* reloadUserManager; + if (plan.system.length > 0) yield* runPrivileged(renderPrivilegedScript(plan, "undo")); + yield* removeDropIns([plan.managerDefaults]); + yield* reloadUserManager; +}); + +/** The plan narrowed to drop-ins that exist, for undo. */ +export const installedProtection = Effect.fn("ServiceProtection.installed")(function* ( + plan: ProtectionPlan, +) { + const fs = yield* FileSystem.FileSystem; + const exists = (dropIn: DropIn) => fs.exists(dropIn.path).pipe(Effect.orElseSucceed(() => false)); + const system = yield* Effect.filter(plan.system, exists); + const user = yield* Effect.filter(plan.user, exists); + const managerDefaults = yield* exists(plan.managerDefaults); + return system.length + user.length === 0 && !managerDefaults + ? undefined + : { ...plan, system, user }; +}); + +export interface ServiceProtectionState { + /** The first cgroup from the top that grants the server no memory protection. */ + readonly unprotected: string | undefined; + readonly oomScoreAdj: number | undefined; + readonly setByProtect: boolean; +} + +/** Reads what the kernel applies to the running service, whoever configured it. */ +export const readProtection = Effect.fn("ServiceProtection.read")(function* (userUnitDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const shown = yield* run("read the service's cgroup", "systemctl", [ + "--user", + "show", + BOOT_SERVICE_UNIT_FILE, + "--property=ControlGroup,MainPID", + ]).pipe(Effect.option); + if (Option.isNone(shown)) return undefined; + const values = parseUnits(shown.value)[0] ?? new Map(); + const controlGroup = (values.get("ControlGroup") ?? "").split("/").filter(Boolean); + if (controlGroup.length === 0) return undefined; + const read = (file: string) => + fs.readFileString(file).pipe( + Effect.map((text) => text.trim()), + Effect.orElseSucceed(() => ""), + ); + let unprotected: string | undefined; + const grants = (value: string) => value !== "" && value !== "0"; + for (let depth = 1; depth <= controlGroup.length && unprotected === undefined; depth++) { + const directory = path.join("/sys/fs/cgroup", ...controlGroup.slice(0, depth)); + const low = yield* read(path.join(directory, "memory.low")); + const min = yield* read(path.join(directory, "memory.min")); + if (!grants(low) && !grants(min)) unprotected = controlGroup[depth - 1]; + } + const pid = Number(values.get("MainPID") ?? 0); + const score = pid > 0 ? yield* read(`/proc/${pid}/oom_score_adj`) : ""; + return { + unprotected, + oomScoreAdj: score === "" ? undefined : Number(score), + setByProtect: yield* fs + .exists(path.join(userUnitDir, `${BOOT_SERVICE_UNIT_FILE}.d`, PROTECT_DROP_IN)) + .pipe(Effect.orElseSucceed(() => false)), + } satisfies ServiceProtectionState; +}); 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/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), ); }), diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 77e4cd11ba071..ad2bc395d083f 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2448,6 +2448,65 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { const task = settled.subagents.find((row) => row.id === child.taskId); assert.equal(task?.status, "cancelled"); assert.equal(task?.completionDelivery?.state, "claimed"); + + // The parent's Stop committed, but the restart cut the effect that stops its children. + const stoppedChild = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "restart-parent-stopped-child", + completionWake: "always", + continuationPending: true, + now, + }); + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make("command:restart-declined-parent:stop"), + events: [ + { + id: EventId.make("event:restart-declined-parent:interrupt-request"), + type: "turn-item.updated", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: TurnItemId.make("turn-item:restart-declined-parent:interrupt-request"), + type: "run_interrupt_request", + threadId, + runId, + nodeId: rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 90, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + message: "Stopped by user", + }, + }, + ], + }); + yield* continueRestartedRun({ + threadId: stoppedChild.childThreadId, + sourceRunId: stoppedChild.childRunId, + }).pipe(Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }))); + const afterStop = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterStop.subagents.find((row) => row.id === stoppedChild.taskId)?.status, + "cancelled", + ); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(stoppedChild.childThreadId)).runs.map( + (run) => run.id, + ), + [stoppedChild.childRunId], + ); }), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 76bcc00bbcb4e..c65005deefb48 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -107,6 +107,8 @@ import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { notificationTurnItem } from "@t3tools/provider-core/server/notification"; import { cancelledBeforeStart, + continuationFailedBeforePrompt, + continuationStartRetryDelayMs, isRestartContinuationSource, runOutcome, } from "./RestartBackgroundNote.ts"; @@ -141,6 +143,7 @@ import { type ProjectionRecords, type ProjectionCheckpointContext, type OlderSettledThreadsListing, + type ActiveShellIndexEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -392,6 +395,11 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly countThreads: () => Effect.Effect; + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -735,6 +743,20 @@ function delegatedTaskTerminalStatus( } } +/** Failed worktree setup keeps the queue held so queued messages cannot start in the project checkout. */ +function worktreeSetupPending( + projection: Pick, +): boolean { + return ( + projection.thread.worktreePath === null && + projection.runs.some( + (run) => + (run.status === "failed" || run.status === "interrupted") && + run.workspacePreparation?.type === "worktree", + ) + ); +} + function nextQueuedRun( projection: Pick, ): OrchestrationV2Run | undefined { @@ -2189,6 +2211,81 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + // Under load a resumed provider can time out before taking the prompt; the restart still owes the turn. + const retryContinuationStart = Effect.fn("Orchestrator.retryContinuationStart")(function* ( + threadId: ThreadId, + runId: RunId, + ) { + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "providerTurns", "turnItems"], + { turnItemTypes: ["error"], turnItemRunIds: [runId] }, + ); + const run = projection.runs.find((candidate) => candidate.id === runId); + if (run === undefined) return; + const delayMs = continuationStartRetryDelayMs(run, projection); + if (delayMs === undefined) return; + const commandId = CommandId.make(`command:restart-continuation-retry:${run.id}`); + yield* writeSystemEvents( + [], + [ + { + id: `effect:restart-continuation:${run.id}`, + commandId, + threadId, + request: { type: "provider-runtime.continue", sourceRunId: run.id }, + availableAt: DateTime.add(yield* DateTime.now, { milliseconds: delayMs }), + }, + ], + ); + }); + + // A hold waits for a person to review what they queued. Nobody reviews messages from agents, + // the server or scheduled tasks, so a later completed run releases those unless it was stopped. + const releaseUnattendedHolds = Effect.fn("Orchestrator.releaseUnattendedHolds")(function* ( + threadId: ThreadId, + completedRunId: RunId, + ) { + const held = yield* projectionStore.getHeldQueuedRuns(threadId); + if (held.length === 0) return; + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "messages", "turnItems"], + { + messageIds: held.map((run) => run.userMessageId), + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [completedRunId], + }, + ); + // Only the newest run completing shows the thread moved past the hold; a late or + // bookkeeping update to an older run, overtaken by a failure or Stop, does not. + if ( + latestExecutedRun(projection.runs)?.id !== completedRunId || + projection.turnItems.length > 0 || + worktreeSetupPending(projection) + ) + return; + const { messages } = projection; + const unattended = new Set( + messages + .filter((message) => message.createdBy !== "user" || message.scheduledTaskId !== undefined) + .map((message) => message.id), + ); + const now = yield* DateTime.now; + const released = held.filter((run) => unattended.has(run.userMessageId)); + if (released.length === 0) return; + yield* writeSystemEvents( + released.map((run) => ({ + type: "run.updated" as const, + threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queueHeld: false }, + })), + ); + }); + const resumeQueuedRuns = Effect.gen(function* () { const threadIds = yield* projectionStore.getRecoveryThreadIds("queued-runs"); let resumed = 0; @@ -5122,7 +5219,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const source = projection.runs.find((run) => run.id === command.restartContinuationOfRunId); if ( !source || - !isRestartContinuationSource(source, projection.providerTurns) || + !( + isRestartContinuationSource(source, projection.providerTurns) || + continuationFailedBeforePrompt(source, projection.providerTurns) + ) || projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.providerInstanceId !== source.providerInstanceId || @@ -5533,15 +5633,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode.type === "queue_after_active"; const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun; // Client messages resume held queues; server continuations and scheduled tasks do not. - // Provider failure or usage limits re-hold the queue. Failed worktree setup keeps it held - // until a worktree exists, so queued messages cannot start in the project checkout. - const worktreeSetupUnresolved = - projection.thread.worktreePath === null && - projection.runs.some( - (run) => - (run.status === "failed" || run.status === "interrupted") && - run.workspacePreparation?.type === "worktree", - ); + // Provider failure or usage limits re-hold the queue. + const worktreeSetupUnresolved = worktreeSetupPending(projection); const sentByUserFromClient = command.createdBy === "user" && (command.creationSource === "web" || command.creationSource === "mobile") && @@ -10360,7 +10453,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A resumed queue can have a lower ordinal than a pending continuation source. // Stop must reach those sources even when interrupting the resumed run succeeds. if ( - run.status === "cancelled" && + (run.status === "cancelled" || + (run.status === "failed" && run.restartContinuationOfRunId !== undefined)) && (yield* awaitsRestartContinuation(run).pipe(mapDispatchError(input.command))) ) { yield* markStoppedRun({ ...input, thread, run }); @@ -13070,6 +13164,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // while writing child events, so nesting parent inside child would invert the order. // The keyed semaphores are neither reentrant nor deadlock-aware. if (delegatedTaskTerminalStatus(run.status) !== null) { + // Before the parent hears of the failure: a pending continuation keeps the task open. + if (run.status === "failed" && run.restartContinuationOfRunId !== undefined) { + yield* threadDispatch.withLock(threadId, retryContinuationStart(threadId, run.id)).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to schedule a restart continuation retry", { + threadId, + runId: run.id, + cause, + }), + ), + ); + } yield* threadDispatch.withLock( threadId, finalizeDelegatedCompletionDelivery(threadId, run.id), @@ -13077,10 +13183,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* threadDispatch .withLock( threadId, - startNextQueuedRun(threadId, { - endedRunId: run.id, - ...(run.status === "failed" ? { failedRunId: run.id } : {}), - }), + (run.status === "completed" + ? releaseUnattendedHolds(threadId, run.id) + : Effect.void + ).pipe( + Effect.andThen( + startNextQueuedRun(threadId, { + endedRunId: run.id, + ...(run.status === "failed" ? { failedRunId: run.id } : {}), + }), + ), + ), ) .pipe( Effect.catchCause((cause) => @@ -13434,6 +13547,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 17b8bb818864a..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; }); @@ -6640,10 +6674,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/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 287a30a9d904e..88a9073117dd6 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, @@ -596,6 +612,9 @@ export interface ProjectionStoreV2Shape { readonly canStartQueuedRun: ( threadId: ThreadId, ) => Effect.Effect; + readonly getHeldQueuedRuns: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getRecoveryThreadIds: ( kind: ProjectionRecoveryKind, ) => Effect.Effect, ProjectionStoreV2Error>; @@ -5167,6 +5186,17 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError(controlReadError(threadId))); + const getHeldQueuedRuns: ProjectionStoreV2Shape["getHeldQueuedRuns"] = (threadId) => + sql` + SELECT payload_json FROM orchestration_v2_projection_runs + WHERE thread_id = ${threadId} AND status = 'queued' + AND json_extract(payload_json, '$.queueHeld') IS 1 + ORDER BY ordinal ASC + `.pipe( + Effect.flatMap(decodeRows(decodeRunPayload, threadId)), + Effect.mapError(controlReadError(threadId)), + ); + const getMessageCount: ProjectionStoreV2Shape["getMessageCount"] = (threadId) => sql<{ count: number; @@ -6614,6 +6644,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 +6890,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), @@ -6789,6 +6913,7 @@ export const layer: Layer.Layer = getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext), getRunMessage: readOnReader(getRunMessage), canStartQueuedRun: readOnReader(canStartQueuedRun), + getHeldQueuedRuns: readOnReader(getHeldQueuedRuns), getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs), hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest), isNodeStored: readOnReader(isNodeStored), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index a39f6b7f036a6..bab115f6e24cb 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"), @@ -261,6 +263,7 @@ it.effect( getRunMessage: () => Effect.die("not used"), isNodeStored: () => Effect.die("not used"), canStartQueuedRun: () => Effect.die("not used"), + getHeldQueuedRuns: () => Effect.die("not used"), getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), getUnreadableThreadIds: () => Effect.die("unused getUnreadableThreadIds"), getThreadSnapshot: () => Effect.die("unused getThreadSnapshot"), diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index ba3830f65453d..24afd61d6186a 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -6,7 +6,7 @@ import type { OrchestrationV2RunAttempt, OrchestrationV2TurnItem, } from "@t3tools/contracts"; -import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { latestRootProviderFailure, runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; type Attempt = Pick; @@ -181,6 +181,50 @@ export function cancelledBeforeStart( return run.status === "cancelled" && neverStarted(run); } +/** The provider never accepted this continuation's prompt, so starting it again repeats nothing. */ +export function continuationFailedBeforePrompt( + run: Pick, + providerTurns: ReadonlyArray>, +): boolean { + return ( + run.status === "failed" && + run.restartContinuationOfRunId !== undefined && + !providerTurns.some((turn) => turn.runAttemptId === run.activeAttemptId) + ); +} + +const CONTINUATION_START_RETRY_DELAYS_MS = [60_000, 180_000]; + +/** Backoff before restarting a continuation that failed to start, or undefined to let it fail. */ +export function continuationStartRetryDelayMs( + run: OrchestrationV2Run, + projection: { + readonly runs: ReadonlyArray; + readonly providerTurns: ReadonlyArray>; + readonly turnItems: Parameters[1]; + }, +): number | undefined { + if (!continuationFailedBeforePrompt(run, projection.providerTurns)) return; + // These fail the same way however often they start. + const failureClass = latestRootProviderFailure(run, projection.turnItems)?.class; + if ( + failureClass === "usage_limit" || + failureClass === "permission_error" || + failureClass === "validation_error" + ) + return; + let failedStarts = 0; + for ( + let current: OrchestrationV2Run | undefined = run; + current !== undefined && continuationFailedBeforePrompt(current, projection.providerTurns); + current = projection.runs.find( + (candidate) => candidate.id === current?.restartContinuationOfRunId, + ) + ) + failedStarts += 1; + return CONTINUATION_START_RETRY_DELAYS_MS[failedStarts - 1]; +} + export function isRestartContinuationSource( source: Pick, providerTurns: ReadonlyArray>, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 061fc54a7f112..d2224720b89f1 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSettings from "../serverSettings.ts"; import { restartContinuationRun, continueRestartedRun } from "./RestartContinuation.ts"; +import { continuationStartRetryDelayMs } from "./RestartBackgroundNote.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -46,6 +47,8 @@ function makeProjection() { providerInstanceId: instanceId, archivedAt: null, deletedAt: null, + lineage: { parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, }, runs: [ { @@ -839,10 +842,13 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = ( + projection: OrchestrationV2ThreadProjection, + sourceRunId: RunId = runId, +) => Effect.gen(function* () { const dispatches: Array> = []; - yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + yield* continueRestartedRun({ threadId, sourceRunId }).pipe( Effect.provide( Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -943,6 +949,118 @@ it.effect("continues a resumed queued run that ran after an earlier continuation }), ); +it("backs off twice from a continuation the provider never took, then lets it fail", () => { + const base = makeProjection(); + const failedStart = (id: string, of: RunId) => ({ + ...base.runs[0]!, + id: RunId.make(id), + activeAttemptId: RunAttemptId.make(`attempt:${id}`), + status: "failed" as const, + restartContinuationOfRunId: of, + }); + const first = failedStart("run:retry-1", runId); + const second = failedStart("run:retry-2", first.id); + const third = failedStart("run:retry-3", second.id); + const projection = (failureClass = "provider_error", providerTurns = base.providerTurns) => + ({ + runs: [{ ...base.runs[0]!, status: "cancelled" }, first, second, third], + providerTurns, + turnItems: [first, second, third].map((run) => ({ + id: `error:${run.id}`, + type: "error", + status: "failed", + runId: run.id, + nodeId: run.rootNodeId, + ordinal: 1, + updatedAt: now, + failure: { class: failureClass, message: "Failed.", code: null, retryable: null }, + })), + }) as unknown as Parameters[1]; + assert.deepEqual( + [first, second, third].map((run) => continuationStartRetryDelayMs(run, projection())), + [60_000, 180_000, undefined], + ); + for (const failureClass of ["usage_limit", "permission_error", "validation_error"]) + assert.isUndefined(continuationStartRetryDelayMs(first, projection(failureClass))); + // Once the provider took the prompt, starting again would repeat the turn. + assert.isUndefined( + continuationStartRetryDelayMs( + first, + projection("provider_error", [ + { ...base.providerTurns[0]!, runAttemptId: first.activeAttemptId, status: "failed" }, + ] as never), + ), + ); +}); + +it.effect("continues again after a continuation fails before its prompt", () => + Effect.gen(function* () { + const cut = cutMidTurn(); + const failedStart = { + ...cut.runs[0]!, + id: RunId.make("run:failed-start"), + ordinal: 2, + activeAttemptId: RunAttemptId.make("attempt:failed-start"), + status: "failed", + restartContinuationOfRunId: runId, + }; + const dispatches = yield* continuationDispatches( + { ...cut, runs: [...cut.runs, failedStart] } as unknown as OrchestrationV2ThreadProjection, + failedStart.id, + ); + assert.deepEqual( + dispatches.map((command) => command.restartContinuationOfRunId), + [failedStart.id], + ); + }), +); + +it.effect.each([ + ["running", null, 0], + ["completed", "Done.", 1], +] as const)( + "a parent's Stop on the task's run blocks the child only while the task is %s", + ([status, result, expected]) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:parent"); + const parentRunId = RunId.make("run:parent"); + const taskId = NodeId.make("node:task"); + const cut = cutMidTurn(); + const child = { + ...cut, + thread: { + ...cut.thread, + lineage: { parentThreadId, relationshipToParent: "subagent" }, + forkedFrom: { type: "node", nodeId: taskId }, + }, + } as unknown as OrchestrationV2ThreadProjection; + const parent = { + thread: { id: parentThreadId }, + subagents: [{ id: taskId, runId: parentRunId, origin: "app_owned", status, result }], + turnItems: [{ id: "turn-item:stop", runId: parentRunId, type: "run_interrupt_request" }], + }; + let dispatched = 0; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (id) => + Effect.succeed((id === parentThreadId ? parent : child) as never), + recoverDelegatedTask: () => Effect.void, + dispatch: () => + Effect.sync(() => { + dispatched += 1; + return {} as never; + }), + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), + ), + ); + assert.equal(dispatched, expected); + }), +); + it.effect("does not continue a run the user asked to stop before the restart", () => Effect.gen(function* () { const texts = yield* continuationTexts({ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index c81faa6cf47cb..e4d7ac2b7f900 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -2,6 +2,7 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, + isOrchestrationV2WorkActive, latestProviderTurnForAttempt, MessageId, type OrchestrationV2Run, @@ -19,6 +20,7 @@ import { awaitingProviderThreadId, cancelledRosterTaskWork, cancelledTurnItemWork, + continuationFailedBeforePrompt, isAwaitedWork, isRestartContinuationSource, cancelledBeforeStart, @@ -200,7 +202,10 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const source = projection.runs.find((run) => run.id === input.sourceRunId); if ( !source || - !isRestartContinuationSource(source, projection.providerTurns) || + !( + isRestartContinuationSource(source, projection.providerTurns) || + continuationFailedBeforePrompt(source, projection.providerTurns) + ) || restartContinuationBlocked( projection, source, @@ -255,6 +260,32 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ) ) return; + // A parent's Stop reaches its open delegated tasks through an effect a restart can cut short. + const { lineage, forkedFrom } = projection.thread; + const taskId = + source.delegatedTaskId ?? (forkedFrom?.type === "node" ? forkedFrom.nodeId : undefined); + if ( + lineage.relationshipToParent === "subagent" && + lineage.parentThreadId !== null && + taskId !== undefined + ) { + const task = (yield* threads.getThreadRecords(lineage.parentThreadId, ["subagents"], { + subagentIds: [taskId], + })).subagents.find((candidate) => candidate.id === taskId); + // A settled task no longer owns later work in its thread. + const parentRunId = + task !== undefined && task.result === null && isOrchestrationV2WorkActive(task.status) + ? task.runId + : null; + if ( + parentRunId !== null && + (yield* threads.getThreadRecords(lineage.parentThreadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [parentRunId], + })).turnItems.length > 0 + ) + return; + } const sourceMessage = sourceRecords.messages.find( (message) => message.id === source.userMessageId, ); 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..b68eef4b2a40c 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) { @@ -1066,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); @@ -1093,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 @@ -1132,7 +1134,7 @@ const make = Effect.gen(function* () { input.threadId, run.id, ); - }; + }); return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); }); 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/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/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/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/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 428202d17b651..20158a80d3ff6 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4768,6 +4768,265 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // A thread fed by agent messages, with helpers to end its runs as a provider would. + const queueTestThread = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Dispatcher", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const send = (key: string, createdBy: "user" | "agent", start: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy, + creationSource: createdBy === "user" ? "web" : "server", + commandId: CommandId.make(`${threadId}:message:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:message:${key}`), + text: key, + attachments: [], + modelSelection, + dispatchMode: { type: start ? "start_immediately" : "queue_after_active" }, + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => { + const run = runs.find( + (candidate) => candidate.userMessageId === MessageId.make(`${threadId}:message:${key}`), + ); + assert.isDefined(run); + return run; + }); + const startedRunIds = yield* Queue.unbounded(); + // Run updates in commit order; the run-update listener handles them in this order too. + const runUpdates: Array = []; + yield* eventSink + .stream({ threadId, afterSequence: yield* orchestrator.getThreadEventSequence(threadId) }) + .pipe( + Stream.runForEach((stored) => { + if (stored.event.type !== "run.updated") return Effect.void; + runUpdates.push(stored.event.payload); + return stored.event.payload.status === "starting" + ? Queue.offer(startedRunIds, stored.event.payload.id) + : Effect.void; + }), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const releasedAfter = (key: string, completedKey: string) => + Effect.gen(function* () { + const [run, completed] = [yield* runFor(key), yield* runFor(completedKey)]; + const released = runUpdates.findIndex( + (update) => update.id === run.id && update.queueHeld === false, + ); + const completion = runUpdates.findIndex( + (update) => update.id === completed.id && update.status === "completed", + ); + assert.isAbove(released, completion, `${key} was released before ${completedKey} ended`); + }); + const end = ( + key: string, + status: "completed" | "failed", + revision = "", + patch: Partial = {}, + ) => + Effect.gen(function* () { + const run = yield* runFor(key); + assert.isNotNull(run.rootNodeId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + ...(status === "failed" + ? [ + { + id: EventId.make(`${threadId}:${key}:error`), + type: "turn-item.updated" as const, + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:${key}:error`), + type: "error" as const, + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: run.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed" as const, + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error" as const, + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + ] + : []), + { + id: EventId.make(`${threadId}:${key}:${status}${revision}`), + type: "run.updated", + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, ...patch, status, startedAt: now, completedAt: now }, + }, + ], + }); + }); + + // The run-update listener places failure holds after the terminal event commits. + const awaitHeld = (key: string) => + Effect.gen(function* () { + const run = yield* runFor(key); + yield* eventSink.stream({ threadId, afterSequence: 0, eventType: "run.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === run.id && + stored.event.payload.queueHeld === true, + ), + Stream.runHead, + ); + }); + return { send, runFor, end, startedRunIds, releasedAfter, awaitHeld }; + }); + + it.effect("releases held agent messages once a later run completes, not the user's", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-failed-queue-agent-messages"); + const { send, runFor, end, startedRunIds, releasedAfter, awaitHeld } = + yield* queueTestThread(threadId); + yield* send("earlier", "agent", true); + yield* end("earlier", "completed"); + yield* send("active", "agent", true); + yield* send("issue", "agent", false); + yield* send("user", "user", false); + yield* end("active", "failed"); + yield* awaitHeld("issue"); + assert.isTrue((yield* runFor("issue")).queueHeld); + // Delivery bookkeeping rewrites an older completed run; the thread has not moved on. + yield* end("earlier", "completed", ":bookkeeping"); + // A scheduled tick finds the thread idle and starts past the held queue. + yield* send("tick", "agent", true); + assert.equal((yield* runFor("tick")).status, "starting"); + yield* end("tick", "completed"); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("issue")).id); + yield* releasedAfter("issue", "tick"); + const user = yield* runFor("user"); + assert.equal(user.status, "queued"); + assert.isTrue(user.queueHeld); + + // A run that finishes despite a stop request does not release what the stop held. + yield* send("after-stop", "agent", false); + assert.isTrue((yield* runFor("after-stop")).queueHeld); + const issue = yield* runFor("issue"); + assert.isNotNull(issue.rootNodeId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:issue:interrupt-request`), + type: "turn-item.updated", + threadId, + runId: issue.id, + nodeId: issue.rootNodeId, + providerInstanceId: issue.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:issue:interrupt-request`), + type: "run_interrupt_request", + threadId, + runId: issue.id, + nodeId: issue.rootNodeId, + providerThreadId: issue.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + message: "Stopped by user", + }, + }, + ], + }); + yield* end("issue", "completed"); + // The next completed run releases it, which also shows the listener handled the stopped one. + yield* send("probe", "agent", true); + assert.equal((yield* runFor("probe")).status, "starting"); + yield* end("probe", "completed"); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("after-stop")).id); + yield* releasedAfter("after-stop", "probe"); + }), + ); + + it.effect("keeps held agent messages while the thread's worktree setup is unresolved", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const dispatcherId = ThreadId.make("runtime-layer-held-queue-worktree"); + const dispatcher = yield* queueTestThread(dispatcherId); + const witness = yield* queueTestThread(ThreadId.make("runtime-layer-held-queue-witness")); + yield* dispatcher.send("setup", "agent", true); + yield* dispatcher.send("issue", "agent", false); + yield* dispatcher.end("setup", "failed", "", { + workspacePreparation: { type: "worktree", baseRef: "main" }, + }); + yield* dispatcher.awaitHeld("issue"); + assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); + yield* dispatcher.send("tick", "agent", true); + assert.equal((yield* dispatcher.runFor("tick")).status, "starting"); + yield* witness.send("active", "agent", true); + yield* witness.send("queued", "agent", false); + // The failed setup never produced the thread's worktree. + const { thread } = yield* orchestrator.getThreadProjection(dispatcherId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${dispatcherId}:worktree-missing`), + type: "thread.metadata-updated", + threadId: dispatcherId, + occurredAt: yield* DateTime.now, + payload: { ...thread, worktreePath: null }, + }, + ], + }); + yield* dispatcher.end("tick", "completed"); + // One listener handles every thread's completions in order, so the witness starting + // means the tick's completion was handled too. + yield* witness.end("active", "completed"); + assert.equal(yield* Queue.take(witness.startedRunIds), (yield* witness.runFor("queued")).id); + assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); + }), + ); + it.effect("keeps queued user selections and pauses only the limited provider", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -5206,6 +5465,165 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([false, true])( + "restarts a continuation that failed before its prompt, unless stopped (%s)", + (stopped) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`runtime-layer-continuation-start-retry-${stopped}`); + const messageId = (key: string) => MessageId.make(`${threadId}:${key}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Continuation start retry", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => + runs.find((candidate) => candidate.userMessageId === messageId(key)), + ); + const write = ( + key: string, + payload: OrchestrationV2Run, + extra: Parameters[0]["events"] = [], + ) => + Effect.gen(function* () { + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + ...extra, + { + id: EventId.make(`${threadId}:${key}`), + type: "run.updated", + threadId, + runId: payload.id, + providerInstanceId: payload.providerInstanceId, + occurredAt: now, + payload: { ...payload, startedAt: now, completedAt: now }, + }, + ], + }); + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:cut`), + threadId, + messageId: messageId("cut"), + text: "Work that the restart cut", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const cut = (yield* runFor("cut"))!; + yield* write("cut:cancelled", { ...cut, status: "cancelled" }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`${threadId}:message:continuation`), + threadId, + messageId: messageId("continuation"), + text: "Continue where you left off.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + restartContinuationOfRunId: cut.id, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:queued`), + threadId, + messageId: messageId("queued"), + text: "Queued behind the continuation", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + const continuation = (yield* runFor("continuation"))!; + assert.isNotNull(continuation.rootNodeId); + const queued = (yield* runFor("queued"))!; + // The failure holds the queue after the reactor scheduled any retry. + const held = yield* eventSink + .stream({ threadId, afterSequence: yield* orchestrator.getThreadEventSequence(threadId) }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === queued.id && + stored.event.payload.queueHeld === true, + ), + Stream.runHead, + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const now = yield* DateTime.now; + yield* write("continuation:failed", { ...continuation, status: "failed" }, [ + { + id: EventId.make(`${threadId}:continuation:error`), + type: "turn-item.updated", + threadId, + runId: continuation.id, + nodeId: continuation.rootNodeId, + providerInstanceId: continuation.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:continuation:error`), + type: "error", + threadId, + runId: continuation.id, + nodeId: continuation.rootNodeId, + providerThreadId: continuation.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error", + message: "Claude timed out while registering its MCP tools.", + code: null, + retryable: null, + }, + }, + }, + ]); + yield* Fiber.join(held); + const retriesOf = Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => + runs.filter((run) => run.restartContinuationOfRunId === continuation.id), + ); + yield* worker.drain(); + assert.deepEqual(yield* retriesOf, [], "the retry waits out its backoff"); + if (stopped) { + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + }); + } + yield* TestClock.adjust("61 seconds"); + yield* worker.drain(); + assert.lengthOf(yield* retriesOf, stopped ? 0 : 1); + }), + ); + it.effect.each(["startup", "shutdown"] as const)( "preserves and holds queued messages across %s until explicitly resumed", (trigger) => @@ -5372,9 +5790,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { after.runs.find((run) => run.id === held.id), held, ); + // Nobody will resume a server message, so the completed run released it behind the wake. assert.deepEqual( after.runs.find((run) => run.id === textContinuation.id), - textContinuation, + { ...textContinuation, queueHeld: false }, ); }), ); @@ -5714,7 +6133,7 @@ it.layer(layerTest)("snooze queued runs", (it) => { payload: run.status === "queued" ? { ...run, queueHeld: true } - : { ...run, status: "completed" as const, completedAt: now }, + : { ...run, status: "cancelled" as const, completedAt: now }, })), effects: [], }); 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/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:" }))), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 0c44cd4131a99..31b7058c60ad4 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -43,6 +43,7 @@ import { SERVER_BROWSER_AUTOMATION_CLIENT_ID, type PreviewAppearancePreference, } from "@t3tools/contracts"; +import { AgentScope, agentScopeLauncherScript } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; @@ -461,6 +462,8 @@ const make = Effect.gen(function* () { const runPromise = Effect.runPromiseWith(yield* Effect.context()); const launchServices = yield* Effect.context(); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); + const agentScope = yield* AgentScope; + const launchersDir = NodePath.join(config.stateDir, "server-browser", "launchers"); const tabs = new Map(); const pendingTabs = new Map>(); @@ -482,6 +485,17 @@ const make = Effect.gen(function* () { onNone: () => previewBrowser.executable, }), ), + // An agent scope keeps Chrome out of the server's memory protection and OOM score. + launcher: async (executable) => { + const launch = await runPromise( + agentScope.wrap({ command: executable, args: [], name: "preview-browser" }), + ); + if (launch.command === executable) return undefined; + await NodeFSP.mkdir(launchersDir, { recursive: true }); + const launcher = NodePath.join(launchersDir, NodeCrypto.randomUUID()); + await NodeFSP.writeFile(launcher, agentScopeLauncherScript(launch), { mode: 0o700 }); + return { path: launcher, release: () => NodeFSP.rm(launcher, { force: true }) }; + }, // Playwright's launch error drops Chrome's own output; its sandbox note survives. // The host setup only covers the headless shell; a packaged browser brings its own. diagnose: (executable, cause) => diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 3772e7f2787a9..6fc0f38cd31b8 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -62,6 +62,17 @@ const options = () => ({ env: {}, }); +const withLauncher = () => { + const release = vi.fn(async () => {}); + return { + release, + configuration: { + ...options(), + launcher: vi.fn(async (executable: string) => ({ path: `${executable}-launcher`, release })), + }, + }; +}; + beforeEach(() => { launches.launch.mockReset(); launches.persistent.mockReset(); @@ -117,19 +128,29 @@ describe("ServerBrowserContexts", () => { it("lazily shares one browser across concurrent isolated sessions and reuses each context", async () => { const browser = makeBrowser(); const launch = Promise.withResolvers(); - launches.launch.mockReturnValue(launch.promise); - const configuration = options(); + const started = Promise.withResolvers(); + launches.launch.mockImplementation(() => { + started.resolve(); + return launch.promise; + }); + const { configuration, release } = withLauncher(); const pool = new ServerBrowserContexts(configuration); expect(configuration.executable).not.toHaveBeenCalled(); const first = pool.contextFor("default", "agent-a"); const again = pool.contextFor("default", "agent-a"); const second = pool.contextFor("default", "agent-b"); const incognito = pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID); + await started.promise; + // Playwright may retry the same path until the launch settles. + expect(release).not.toHaveBeenCalled(); launch.resolve(browser as unknown as Browser); const [a, reused, b, privateContext] = await Promise.all([first, again, second, incognito]); + expect(release).toHaveBeenCalledTimes(1); expect(a).toBe(reused); expect(new Set([a, b, privateContext]).size).toBe(3); - expect(launches.launch).toHaveBeenCalledTimes(1); + expect(launches.launch).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ executablePath: "/test/chromium-launcher" }), + ); expect(launches.persistent).not.toHaveBeenCalled(); expect(browser.newContext).toHaveBeenCalledWith({ viewport: { width: 1280, height: 800 }, @@ -145,19 +166,44 @@ describe("ServerBrowserContexts", () => { const browser = makeBrowser(); launches.persistent.mockResolvedValue(human as unknown as BrowserContext); launches.launch.mockResolvedValue(browser as unknown as Browser); - const pool = new ServerBrowserContexts(options()); + const { configuration, release } = withLauncher(); + const pool = new ServerBrowserContexts(configuration); const persistent = await pool.contextFor("work/team"); expect(await pool.contextFor("work/team")).toBe(persistent); expect(await pool.contextFor("work/team", "agent")).not.toBe(persistent); expect(launches.persistent).toHaveBeenCalledExactlyOnceWith( "/test/profiles/work%2Fteam", - expect.objectContaining({ chromiumSandbox: true, executablePath: "/test/chromium" }), + expect.objectContaining({ chromiumSandbox: true, executablePath: "/test/chromium-launcher" }), ); + expect(release).toHaveBeenCalledTimes(2); expect(launches.mkdir).toHaveBeenCalledWith("/test/profiles/work%2Fteam", { recursive: true }); await pool.close(); expect(human.close).toHaveBeenCalledTimes(1); }); + it("diagnoses a launcher failure against the real executable", async () => { + const diagnosed = new Error("diagnosed"); + const diagnose = vi.fn(async () => diagnosed); + const pool = new ServerBrowserContexts({ + ...options(), + diagnose, + launcher: vi.fn(async () => { + throw new Error("EACCES"); + }), + }); + await expect(pool.contextFor("default", "agent")).rejects.toBe(diagnosed); + expect(diagnose).toHaveBeenCalledExactlyOnceWith("/test/chromium", expect.any(Error)); + expect(launches.launch).not.toHaveBeenCalled(); + }); + + it("creates no launcher for a profile whose storage cannot be created", async () => { + launches.mkdir.mockRejectedValueOnce(new Error("EACCES")); + const { configuration } = withLauncher(); + const pool = new ServerBrowserContexts(configuration); + await expect(pool.contextFor("work/team")).rejects.toThrow("EACCES"); + expect(configuration.launcher).not.toHaveBeenCalled(); + }); + it("clears a profile by closing its open context before deleting its storage", async () => { const human = makeContext(); const browser = makeBrowser(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 245b17d06ea64..5c00207978b79 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -19,6 +19,13 @@ interface Options { readonly platform: NodeJS.Platform; /** The shared headless browser's executable, installing it on first use. */ readonly executable: () => Promise; + /** + * A path that starts `executable`, such as one that moves Chrome out of the server's cgroup. + * Released after the whole launch call, since Playwright retries the same path after a glibc race. + */ + readonly launcher?: ( + executable: string, + ) => Promise<{ readonly path: string; readonly release: () => Promise } | undefined>; readonly diagnose?: (executable: string, cause: unknown) => Promise; readonly env?: NodeJS.ProcessEnv; readonly onContextClose?: (context: BrowserContext) => void; @@ -95,10 +102,9 @@ export class ServerBrowserContexts { } private async launchOptions() { - const executablePath = await this.options.executable(); + const executable = await this.options.executable(); const env = this.options.env ?? process.env; const options = { - executablePath, env, // People drive these tabs, so they must not announce automation (navigator.webdriver). args: ["--force-device-scale-factor=2", "--disable-blink-features=AutomationControlled"], @@ -107,14 +113,18 @@ export class ServerBrowserContexts { // Only an explicit operator opt-out disables sandboxing. Launch errors never do. chromiumSandbox: !sandboxDisabled(env), }; - return { ...options, args: [...(await this.renderingArgs(options)), ...options.args] }; + return { + executable, + ...options, + args: [...(await this.renderingArgs(executable, options)), ...options.args], + }; } /** Decided once per server: the GPU when it composites, otherwise software compositing. */ - private renderingArgs(options: LaunchOptions & { readonly executablePath: string }) { + private renderingArgs(executable: string, options: LaunchOptions) { if (this.options.platform !== "linux") return Promise.resolve(SOFTWARE_RENDERING); if (!this.rendering) { - const probing = this.probeGpu(options); + const probing = this.probeGpu(executable, options); this.rendering = probing; void probing.catch(() => { if (this.rendering === probing) this.rendering = undefined; @@ -123,10 +133,12 @@ export class ServerBrowserContexts { return this.rendering; } - private async probeGpu(options: LaunchOptions & { readonly executablePath: string }) { + private async probeGpu(executable: string, options: LaunchOptions) { const { chromium } = loadPlaywright(); const launchOptions = { ...options, args: [...GPU_RENDERING, ...(options.args ?? [])] }; - const browser = await this.launch(options, () => chromium.launch(launchOptions)); + const browser = await this.launch(executable, (executablePath) => + chromium.launch({ ...launchOptions, executablePath }), + ); try { const cdp = await browser.newBrowserCDPSession(); const { gpu } = await cdp.send("SystemInfo.getInfo"); @@ -138,19 +150,26 @@ export class ServerBrowserContexts { } } - private async launch(options: { readonly executablePath: string }, start: () => Promise) { + private async launch(executable: string, start: (executablePath: string) => Promise) { + let release: (() => Promise) | undefined; try { - return await start(); + const launcher = await this.options.launcher?.(executable); + release = launcher?.release; + return await start(launcher?.path ?? executable); } catch (cause) { - throw (await this.options.diagnose?.(options.executablePath, cause)) ?? cause; + throw (await this.options.diagnose?.(executable, cause)) ?? cause; + } finally { + await release?.().catch(constVoid); } } private sharedBrowser() { if (!this.browser) { - const launched = this.launchOptions().then(async (options) => { + const launched = this.launchOptions().then(async ({ executable, ...options }) => { const { chromium } = loadPlaywright(); - const browser = await this.launch(options, () => chromium.launch(options)); + const browser = await this.launch(executable, (executablePath) => + chromium.launch({ ...options, executablePath }), + ); browser.on("disconnected", () => { if (this.browser === launched) this.browser = undefined; }); @@ -198,11 +217,15 @@ export class ServerBrowserContexts { return browser.newContext(contextOptions); } const directory = this.profileDirectory(profileId); - const options = await this.launchOptions(); - const { chromium } = loadPlaywright(); await NodeFSP.mkdir(directory, { recursive: true }); - return this.launch(options, () => - chromium.launchPersistentContext(directory, { ...options, ...contextOptions }), + const { executable, ...options } = await this.launchOptions(); + const { chromium } = loadPlaywright(); + return this.launch(executable, (executablePath) => + chromium.launchPersistentContext(directory, { + ...options, + ...contextOptions, + executablePath, + }), ); } diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 94ca314ac1f33..286452a5c5781 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -245,6 +245,34 @@ describe("AgentScope service", () => { ), ); + it.effect("caps agent memory and yields CPU, disk, and tasks to the server", () => + withFakeSystemd(({ scope, calls }) => + Effect.gen(function* () { + yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + const setProperties = calls.filter(([, verb]) => verb === "set-property"); + expect(setProperties).toEqual([ + [ + "--user", + "set-property", + "--runtime", + "app-t3code-agents.slice", + expect.stringMatching(/^MemoryHigh=\d+$/), + expect.stringMatching(/^MemoryMax=\d+$/), + ], + [ + "--user", + "set-property", + "--runtime", + "app-t3code.slice", + "CPUWeight=10", + "IOWeight=10", + "TasksMax=25%", + ], + ]); + }), + ), + ); + it.effect("stops agents a killed server left before the first launch", () => withFakeSystemd(({ scope, units, calls, stateDir }) => Effect.gen(function* () { diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index cc6d3a04121f6..c72e4eed88a9c 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -19,6 +19,12 @@ import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; const AGENT_SLICE = "app-t3code-agents.slice"; +// Owns every agent slice and competes with the server's unit or scope under app.slice. +const AGENTS_PARENT_SLICE = "app-t3code.slice"; +// Low weights yield CPU and disk to the server under contention, where the user manager +// has those controllers: current systemd delegates cpu, rarely io. TasksMax keeps agents +// below logind's 33% per-user task limit, leaving forks for the server. +const AGENTS_PARENT_PROPERTIES = ["CPUWeight=10", "IOWeight=10", "TasksMax=25%"]; export function ownedAgentSlice(stateDir: string): string { const hash = NodeCrypto.createHash("sha256").update(stateDir).digest("hex").slice(0, 12); @@ -173,20 +179,28 @@ export const make = Effect.gen(function* () { yield* Effect.logInfo("Agent scopes are off: no systemd user manager"); return undefined; } + const setProperties = Effect.fn("agentScope.setProperties")(function* ( + slice: string, + properties: ReadonlyArray, + ) { + const result = yield* run(systemctl, [ + "--user", + "set-property", + "--runtime", + slice, + ...properties, + ]); + if (result._tag === "None" || result.value.code !== 0) { + yield* Effect.logWarning("Could not set resource limits on an agents slice", { + slice, + properties, + stderr: result._tag === "Some" ? result.value.stderr.trim() : undefined, + }); + } + }); const limits = agentSliceMemoryLimits(NodeOS.totalmem()); - const limited = yield* run(systemctl, [ - "--user", - "set-property", - "--runtime", - AGENT_SLICE, - `MemoryHigh=${limits.high}`, - `MemoryMax=${limits.max}`, - ]); - if (limited._tag === "None" || limited.value.code !== 0) { - yield* Effect.logWarning("Could not set memory limits on the agents slice", { - stderr: limited._tag === "Some" ? limited.value.stderr.trim() : undefined, - }); - } + yield* setProperties(AGENT_SLICE, [`MemoryHigh=${limits.high}`, `MemoryMax=${limits.max}`]); + yield* setProperties(AGENTS_PARENT_SLICE, AGENTS_PARENT_PROPERTIES); yield* Effect.logInfo("Agent scopes are on", { slice: AGENT_SLICE, ...limits, 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 2b9aee31c56e3..7dd0dd06e25fd 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/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/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/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 9097b92947af3..c9d4bcf51ad46 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1083,6 +1083,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", @@ -1614,6 +1662,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", @@ -1624,7 +1673,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"); @@ -1646,6 +1695,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)), 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/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 }); + }), + ); }, ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index da660eb70bf0c..2feea8f9350af 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -414,12 +414,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 0818d4f848ee4..354315ab6fc8e 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, @@ -1012,21 +1011,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, 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 aff3cc0be9fad..4b9ecf74a8324 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/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 866612bc11009..ca98e919ff93d 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -1,7 +1,10 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + isAttachmentOnlyBootstrapPrompt, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import type { ChatAttachment as ContractChatAttachment, EnvironmentId, @@ -237,11 +240,13 @@ export function QueuedRunsControl({
    {visibleItems.map((item, position) => { - const previewText = assistantCitationsToPlainText( - replaceComposerContextReferences(item.text, (reference) => - reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label, - ), - ).trim(); + const previewText = isAttachmentOnlyBootstrapPrompt(item.text) + ? "" + : assistantCitationsToPlainText( + replaceComposerContextReferences(item.text, (reference) => + reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label, + ), + ).trim(); const isEditing = item.runId !== null && item.runId === props.editingRunId; return (
  1. diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index efc1701cfd720..fba369fb1d497 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -81,6 +81,10 @@ import { import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; +// Drawn inside the media so dark screenshots keep an edge on the dark background. +const MEDIA_EDGE_CLASS_NAME = + "relative after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-black/9 dark:after:ring-white/12"; + const BUBBLE_CHIP_CLASS_NAME = "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]"; @@ -327,9 +331,7 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; const chippedAttachmentIds = new Set( @@ -340,6 +342,27 @@ function UserTimelineRow({ row }: { row: Extract !image.name.startsWith("preview-annotation-")); const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id)); + // Images or videos sent without words show as media, not as a bubble around them. + const mediaOnly = + !folded && + userMessage.attribution !== "guest" && + resolvedContext.text.trim() === "" && + userAudio.length === 0 && + unchippedFiles.length === 0 && + unknownAttachments.length === 0 && + regularImages.length + userVideos.length > 0; + // A lone image keeps its own shape; a 4:3 crop hides most of a wide screenshot. + const loneImage = + mediaOnly && + userVideos.length === 0 && + regularImages.length === 1 && + regularImages[0]!.source?.kind !== "snap-shot"; + // Several images sent without words share one rounded shape, split by hairlines. + const collage = + mediaOnly && + userVideos.length === 0 && + regularImages.length > 1 && + regularImages.every((image) => image.source?.kind !== "snap-shot"); const annotationRecordIds = resolvedContext.records .filter((record) => record.kind === "preview-annotation") .map((record) => record.contextId); @@ -472,8 +495,8 @@ function UserTimelineRow({ row }: { row: Extract @@ -505,15 +528,45 @@ function UserTimelineRow({ row }: { row: Extract {(regularImages.length > 0 || userVideos.length > 0) && ( -
    - {regularImages.map((image) => ( +
    + {regularImages.map((image, index) => (
    3 && + regularImages.length % 2 === 1 && + index === regularImages.length - 1 && + "col-span-2", + ) + : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80", )} > {image.previewUrl ? ( @@ -535,11 +588,23 @@ function UserTimelineRow({ row }: { row: Extract ) : ( -
    +
    {image.name}
    )} 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 (
    ) : (

    - 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/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/docs/internals/devices.md b/docs/internals/devices.md index 5273f7942e9e0..28fa103feb5cb 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -36,13 +36,13 @@ its sessions. Sessions it starts are recorded under the state directory and stopped by the next server if this one dies; sessions other tools started on the same account are never touched. -Each session brings its own serve-sim preview host and agent-device daemon, -with their own tokens. The proxy asks the host to route each request -(`DeviceHostReady.upstream`) and adds the session token server-side, so +Each session brings its own preview host (serve-sim, or serve-emu for Android) +and agent-device daemon, with their own tokens. The proxy asks the host to +route each request (`DeviceHostReady.upstream`) and adds the session token server-side, so clients never hold it. Agent config files are written per device (`DeviceOperations.agentEndpoint`), which is why `device_open` opens the -device before targeting it. Device settings run `simctl` on the simulator's -machine, which EAS does not expose, so they are unavailable there. +device before targeting it. Device settings and tools run `simctl` or `adb` on +the device's machine, which EAS does not expose, so they are unavailable there. ## Simlock hosts lease through the agent-device front @@ -147,8 +147,8 @@ decoder then retains frames for seconds despite `optimizeForLatency` and an empt `decodeQueueSize`. The iOS AVCC viewer restores the missing metadata before configuring the decoder so hardware decoding can remain enabled. WebCodecs is secure-context only, so the viewer probes `isConfigSupported` and falls back to -MJPEG on iOS when decoding is unavailable. Android has no MJPEG; there the panel -reports that it cannot decode. +MJPEG on iOS when decoding is unavailable. Android's SEMU socket has no MJPEG; +there the panel reports that it cannot decode. serve-sim can also be locked to WebRTC, as on EAS Simulator, and then refuses AVCC with a 409 naming the transport. The viewer still asks for AVCC first, so @@ -156,3 +156,6 @@ hubs that serve it pay nothing, and switches on that 409, or when its MJPEG image fails in a browser that has `RTCPeerConnection`. Offers are not trickled: serve-sim takes one offer with every candidate. The video element only feeds the same canvas sink, so the panel and the mobile WebView keep one surface. +Android asks serve-emu's `/api` before opening its video socket and streams +WebRTC when it is advertised (EAS Simulator), keeping a separate `video=0` +socket for input so input can reconnect without restarting video. diff --git a/docs/user/background-service.md b/docs/user/background-service.md index be4ad66ff3ab4..f9ab85fcac4c3 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -90,6 +90,24 @@ Windows background services are not supported. T3 Connect can offer service installation during setup, but the two are managed separately. Signing out of T3 Connect does not stop or uninstall the service. +## Keep the server responsive when agents use all memory + +On Linux, agents already run with lower priority than the server. When they +fill the machine's memory, though, the kernel may still slow down or stop the +server, because only an administrator can reserve memory for it. Run, as your +normal user: + +```sh +t3 service protect +``` + +It shows the system settings it will change, asks before applying them, and +then asks for your sudo password. If the machine runs Tailscale or SSH, it +also offers to protect them, so you can still reach the machine. Memory +protection takes effect at once. The server's protection from the kernel's +out-of-memory killer takes effect after `t3 service restart`. `t3 service status` reports whether +the protection is in place, and `t3 service protect --undo` removes it. + ## Troubleshooting Start with `t3 service status` on the host. It prints the log path and, on Linux, diff --git a/docs/user/devices.md b/docs/user/devices.md index ce5a7d8d06d05..46a5a427cc87c 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -94,7 +94,7 @@ started from then on; your own Device panel is unaffected. The device stream goes through the environment server, so it works over the local network, Tailscale, and T3 Connect. Live video needs a secure page (HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower -still-image stream and Android cannot show video. +still-image stream and Android cannot show video, except on EAS Simulator. ## SSH device hosts 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( diff --git a/packages/client-runtime/src/device/stream.test.ts b/packages/client-runtime/src/device/stream.test.ts index 737daf5158c04..09f04c52780e2 100644 --- a/packages/client-runtime/src/device/stream.test.ts +++ b/packages/client-runtime/src/device/stream.test.ts @@ -75,7 +75,7 @@ describe("native device stream transport", () => { } } vi.stubGlobal("WebSocket", FakeSocket); - const fetch = vi.fn(() => Promise.resolve(new Response("frame"))); + const fetch = vi.fn(() => Promise.resolve(Response.json({}))); vi.stubGlobal("fetch", fetch); const events = { onStatus: vi.fn(), @@ -251,6 +251,7 @@ function recoveryFixture( const fetch = vi.fn((url: string, init: RequestInit) => { const signal = init.signal!; signals.push(signal); + if (url.includes("/api?")) return Promise.resolve(Response.json({})); if (!url.includes("stream.avcc")) return Promise.resolve(new Response("prime")); return Promise.resolve( new Response( @@ -450,9 +451,11 @@ describe("shared device stream readiness and recovery", () => { }), ); client.start(); + await vi.advanceTimersByTimeAsync(0); sockets[0]!.onmessage?.({ data: sps }); client.stop(); client.start(); + await vi.advanceTimersByTimeAsync(0); resolveSupport({ supported: true }); await vi.advanceTimersByTimeAsync(0); expect(decoders).toHaveLength(0); @@ -491,3 +494,136 @@ describe("shared device stream readiness and recovery", () => { expect(vi.getTimerCount()).toBe(0); }); }); + +describe("Android WebRTC device streams", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + function setup() { + vi.useFakeTimers(); + const sockets: Socket[] = []; + class Socket { + static OPEN = 1; + readyState = 1; + binaryType = ""; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; + send = vi.fn(); + close = vi.fn(); + readonly url: string; + constructor(url: string) { + this.url = url; + sockets.push(this); + } + } + const videos: Video[] = []; + class Video { + muted = false; + playsInline = false; + style = { cssText: "" }; + srcObject: unknown = null; + videoWidth = 400; + videoHeight = 800; + setAttribute = vi.fn(); + remove = vi.fn(); + play = vi.fn(async () => {}); + callback: (() => void) | null = null; + requestVideoFrameCallback(callback: () => void) { + this.callback = callback; + return 0; + } + constructor() { + videos.push(this); + } + } + const peers: Peer[] = []; + class Peer { + iceGatheringState = "complete"; + connectionState = "connected"; + localDescription = { type: "offer", sdp: "offer-sdp" }; + ontrack: ((event: { streams: unknown[]; track: unknown }) => void) | null = null; + onconnectionstatechange: (() => void) | null = null; + close = vi.fn(); + createOffer = vi.fn(async () => this.localDescription); + setLocalDescription = vi.fn(async () => {}); + setRemoteDescription = vi.fn(async () => {}); + addTransceiver = vi.fn(() => ({ setCodecPreferences: vi.fn() })); + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + constructor() { + peers.push(this); + } + } + vi.stubGlobal("WebSocket", Socket); + vi.stubGlobal("RTCPeerConnection", Peer); + vi.stubGlobal("RTCRtpReceiver", { getCapabilities: () => ({ codecs: [] }) }); + vi.stubGlobal("document", { createElement: () => new Video(), body: { append: vi.fn() } }); + const fetch = vi.fn((url: string, _init?: RequestInit) => + Promise.resolve( + url.includes("/api?") + ? Response.json({ serial: "emulator-5554", stream: { transport: "webrtc" } }) + : Response.json({ type: "answer", sdp: "answer-sdp" }), + ), + ); + vi.stubGlobal("fetch", fetch); + const events = { + onStatus: vi.fn(), + onScreen: vi.fn(), + onUnauthorized: vi.fn(), + onMjpegFallback: vi.fn(), + onInputConnected: vi.fn(), + }; + const present = vi.fn(() => true); + const client = createDeviceStreamClient( + { + platform: "android", + deviceId: "session-1", + access: { + httpBase: "https://test/api/device-hub", + wsBase: "wss://test/api/device-hub", + credentials: true, + query: { hostId: "eas" }, + }, + }, + { present }, + events, + ); + return { client, events, fetch, peers, videos, sockets, present }; + } + + it("streams over WebRTC when serve-emu advertises it, with input that reconnects on its own", async () => { + const { client, events, fetch, peers, videos, sockets, present } = setup(); + client.start(); + await vi.advanceTimersByTimeAsync(0); + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://test/api/device-hub/vendor/serve-emu/api?device=session-1&hostId=eas", + "https://test/api/device-hub/vendor/serve-emu/webrtc/offer?device=session-1&hostId=eas", + ]); + expect(peers[0]?.setRemoteDescription).toHaveBeenCalledWith({ + type: "answer", + sdp: "answer-sdp", + }); + peers[0]?.ontrack?.({ streams: [{}], track: {} }); + videos[0]?.callback?.(); + expect(present).toHaveBeenCalledWith(videos[0], 400, 800); + expect(events.onStatus).toHaveBeenLastCalledWith("streaming", undefined); + + expect(sockets.map((socket) => socket.url)).toEqual([ + "wss://test/api/device-hub/vendor/serve-emu/ws?device=session-1&video=0&hostId=eas", + ]); + sockets[0]!.onopen?.(); + client.pressButton("back"); + expect(sockets[0]!.send).toHaveBeenCalledWith(JSON.stringify({ type: "back" })); + events.onStatus.mockClear(); + sockets[0]!.onclose?.({ code: 1001, reason: "going away" }); + await vi.advanceTimersByTimeAsync(1_000); + expect(sockets[1]?.url).toContain("video=0"); + expect(peers).toHaveLength(1); + expect(peers[0]?.close).not.toHaveBeenCalled(); + expect(events.onStatus).not.toHaveBeenCalled(); + client.stop(); + }); +}); diff --git a/packages/client-runtime/src/device/stream.ts b/packages/client-runtime/src/device/stream.ts index c4b8078597245..a8913d5a41037 100644 --- a/packages/client-runtime/src/device/stream.ts +++ b/packages/client-runtime/src/device/stream.ts @@ -836,9 +836,10 @@ export function createDeviceStreamClient( /** * serve-sim can be locked to WebRTC (EAS Simulator always is), and then - * refuses AVCC and MJPEG. An unreadable answer keeps the AVCC path. + * refuses AVCC and MJPEG; serve-emu advertises WebRTC the same way. An + * unreadable answer keeps the default path. */ - const probeIosTransport = async (): Promise => { + const probeWebRtc = async (): Promise => { const probe = new AbortController(); const timeout = setTimeout(() => probe.abort(), TRANSPORT_PROBE_TIMEOUT_MS); try { @@ -858,6 +859,9 @@ export function createDeviceStreamClient( } }; + const webRtcUrl = (action: "offer" | "close") => + httpUrl(avcc ? `/helper/${device}/webrtc/${action}` : `/webrtc/${action}?device=${device}`); + const closePeer = () => { const current = peer; if (!current) return; @@ -865,8 +869,8 @@ export function createDeviceStreamClient( current.connection.close(); current.video.srcObject = null; current.video.remove(); - // Releases serve-sim's capture session; best effort because the page may be unloading. - void fetch(httpUrl(`/helper/${device}/webrtc/close`), { + // Releases the capture session; best effort because the page may be unloading. + void fetch(webRtcUrl("close"), { method: "POST", keepalive: true, headers: { "content-type": "application/json" }, @@ -875,7 +879,7 @@ export function createDeviceStreamClient( }).catch(() => {}); }; - const readIosWebRtc = async (settings: WebRtcSettings) => { + const readWebRtc = async (settings: WebRtcSettings) => { const lifecycle = generation; const feedGeneration = ++videoGeneration; closePeer(); @@ -920,7 +924,7 @@ export function createDeviceStreamClient( if (!isCurrent()) return; closePeer(); connecting(detail); - scheduleRetry("video", () => void readIosWebRtc(settings)); + scheduleRetry("video", () => void readWebRtc(settings)); }; const presentFrame = () => { if (!isCurrent()) { @@ -988,7 +992,7 @@ export function createDeviceStreamClient( iceGatheringDeadlineMs = Math.min(iceGatheringDeadlineMs * 2, ICE_GATHERING_MAX_TIMEOUT_MS); return retry("Device stream found no network candidates yet. Retrying…"); } - const response = await fetch(httpUrl(`/helper/${device}/webrtc/offer`), { + const response = await fetch(webRtcUrl("offer"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -1023,8 +1027,7 @@ export function createDeviceStreamClient( const switchToWebRtc = async (failure: string, locked: boolean) => { const session = generation; if (typeof RTCPeerConnection === "undefined") return fail(failure); - const settings = - (await probeIosTransport()) ?? (locked ? { transport: "webrtc" as const } : null); + const settings = (await probeWebRtc()) ?? (locked ? { transport: "webrtc" as const } : null); if (stopped || generation !== session) return; if (!settings) return fail(failure); if (mjpeg) { @@ -1036,7 +1039,7 @@ export function createDeviceStreamClient( // MJPEG may have shown frames already: say the stream is connecting again // rather than leave a blank canvas until the first WebRTC frame. connecting("Switching to WebRTC."); - void readIosWebRtc(settings); + void readWebRtc(settings); }; const startDuoVideo = (panels: DuoPanelSinks) => { @@ -1161,18 +1164,21 @@ export function createDeviceStreamClient( ws.onerror = () => ws.close(); }; - const connectAndroid = () => { + /** `inputOnly` keeps a socket for input beside a WebRTC feed, so its reconnects leave video alone. */ + const connectAndroid = (inputOnly = false) => { if (stopped) return; - const ws = new WebSocket(wsUrl(`/ws?device=${device}&frame-meta=1`)); + const ws = new WebSocket( + wsUrl(`/ws?device=${device}&${inputOnly ? "video=0" : "frame-meta=1"}`), + ); ws.binaryType = "arraybuffer"; socket = ws; ws.onopen = () => { if (stopped || socket !== ws) return; - connecting(); + if (!inputOnly) connecting(); events.onInputConnected(true); }; ws.onmessage = (event) => { - if (stopped || socket !== ws) return; + if (stopped || socket !== ws || inputOnly) return; if (typeof event.data === "string") { // The encoder restarts at a new size when the device rotates; the // next keyframe carries a fresh SPS, so the decoder is rebuilt from it. @@ -1224,13 +1230,29 @@ export function createDeviceStreamClient( (event.code === 1006 && access.query.wsTicket) ) return handleUnauthorized(); - configuring = false; - connecting(event.reason || undefined); - scheduleRetry("input", connectAndroid); + if (!inputOnly) { + configuring = false; + connecting(event.reason || undefined); + } + // A full socket re-probes, so one failed /api read does not pin SEMU on a WebRTC host. + scheduleRetry("input", () => (inputOnly ? connectAndroid(true) : void startAndroid())); }; ws.onerror = () => ws.close(); }; + const startAndroid = async () => { + const session = generation; + const settings = await probeWebRtc(); + if (stopped || generation !== session) return; + if (settings) { + if (typeof RTCPeerConnection === "undefined") + return fail("This browser cannot receive the device's WebRTC stream."); + if (!target.videoOnly) connectAndroid(true); + void readWebRtc(settings); + } else if (useWebCodecs) connectAndroid(); + else fail("This browser cannot decode the Android stream (WebCodecs unavailable)."); + }; + const start = () => { if (!stopped) return; stopped = false; @@ -1247,11 +1269,7 @@ export function createDeviceStreamClient( if (!target.videoOnly) void connectIosInput(); if (useWebCodecs) void readIosVideo(); else fallBackToMjpeg(); - } else if (useWebCodecs) { - connectAndroid(); - } else { - fail("This browser cannot decode the Android stream (WebCodecs unavailable)."); - } + } else void startAndroid(); }; const stop = () => { 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/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/client-runtime/src/userMessage.test.ts b/packages/client-runtime/src/userMessage.test.ts index 0447dc54d1b26..207939cf2f39d 100644 --- a/packages/client-runtime/src/userMessage.test.ts +++ b/packages/client-runtime/src/userMessage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { GuestMessageId } from "@t3tools/contracts"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { resolveUserMessagePresentation } from "./userMessage.ts"; @@ -21,6 +22,19 @@ describe("resolveUserMessagePresentation", () => { } }); + it("hides the prompt that stands in for an attachment-only message", () => { + for (const text of [ + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ]) { + expect(resolveUserMessagePresentation({ role: "user", text })).toEqual({ + text: "", + attribution: null, + scheduledTaskId: undefined, + }); + } + }); + it("shows a guest's own words and name, not the attributed text the agent reads", () => { expect( resolveUserMessagePresentation({ diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index d5d7c5ac08956..8a80f31b155d0 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -5,6 +5,7 @@ import { type OrchestrationV2IdleCompaction, ScheduledTaskId, } from "@t3tools/contracts"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; /** The working row while T3 Code's own compaction runs. */ export const IDLE_COMPACTING_LABELS = { @@ -68,7 +69,8 @@ export function resolveUserMessagePresentation(message: { }; } return { - text: message.text, + // The agent needs words to start a turn; people sent only the attachments. + text: isAttachmentOnlyBootstrapPrompt(message.text) ? "" : message.text, attribution: message.createdBy !== "agent" ? null 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, }), diff --git a/packages/shared/src/AgentScope.test.ts b/packages/shared/src/AgentScope.test.ts index cf3ca64acce0a..2d9aca5ac1129 100644 --- a/packages/shared/src/AgentScope.test.ts +++ b/packages/shared/src/AgentScope.test.ts @@ -1,9 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { AGENT_OOM_SCORE_ADJ, agentScopeCommand } from "./AgentScope.ts"; +import { AGENT_OOM_SCORE_ADJ, agentScopeCommand, agentScopeLauncherScript } from "./AgentScope.ts"; import * as HostProcess from "./HostProcess.ts"; describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("agentScopeCommand", () => { @@ -25,5 +28,20 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("agentScopeComm expect(Number(pid)).toBe(child.pid); expect(Number(score)).toBe(AGENT_OOM_SCORE_ADJ); expect(args).toEqual(["a b", "$HOME"]); + + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agent-launcher-")); + try { + const launcher = NodePath.join(directory, "launch's"); + NodeFS.writeFileSync(launcher, agentScopeLauncherScript(launch), { mode: 0o700 }); + const launched = NodeChildProcess.spawnSync(launcher, ["--flag", "it's"], { + encoding: "utf8", + }); + const [launchedPid, launchedScore, ...launchedArgs] = launched.stdout.trimEnd().split("\n"); + expect(Number(launchedPid)).toBe(launched.pid); + expect(Number(launchedScore)).toBe(AGENT_OOM_SCORE_ADJ); + expect(launchedArgs).toEqual(["a b", "$HOME", "--flag", "it's"]); + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } }); }); diff --git a/packages/shared/src/AgentScope.ts b/packages/shared/src/AgentScope.ts index 361bfc49108c6..ce2d58c017b25 100644 --- a/packages/shared/src/AgentScope.ts +++ b/packages/shared/src/AgentScope.ts @@ -74,6 +74,14 @@ export interface AgentSystemdScope { readonly options: ReadonlyArray; } +const quoteShell = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + +/** An executable file for launchers that take a path, not a command line, such as Playwright. */ +export function agentScopeLauncherScript(launch: AgentScopeCommand): string { + const command = [launch.command, ...launch.args].map(quoteShell).join(" "); + return `#!/bin/sh\nexec ${command} "$@"\n`; +} + /** Exec at every step preserves the agent PID, stdio, and signals; systemd-run places it and descendants in the scope. */ export function agentScopeCommand(input: { readonly command: string; 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…"; +} diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index e1b25bc094a66..88361606a9a64 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -132,6 +132,11 @@ export function collectComposerContextReferences( export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; +/** Claude's ultrathink effort sends the prompt as `Ultrathink:\n`. */ +export function isAttachmentOnlyBootstrapPrompt(text: string): boolean { + return text.replace(/^Ultrathink:\s*/i, "") === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT; +} + export function isImageReferencesOnly(text: string): boolean { const references = collectComposerContextReferences(text); return ( 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({ diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 0dd6a87ae1dea..77a0874c21336 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { expect, it } from "vite-plus/test"; import { serializeAssistantCitation } from "./assistantCitations.ts"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./composerContextReferences.ts"; import { searchableMessageSegments } from "./threadFindText.ts"; it("excludes review attachments rendered as context chips", () => { @@ -31,6 +32,16 @@ it("excludes structured context chips without joining text across them", () => { ).toEqual(["before", "after"]); }); +it("excludes the hidden prompt of an attachment-only message", () => { + expect( + searchableMessageSegments({ + role: "user", + streaming: false, + text: ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + }), + ).toEqual([]); +}); + it("does not upgrade literal legacy tags in messages with structured context", () => { const text = "\n- Terminal 1 line 12:\n 12 | visible output\n"; diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index a2e5b4dbb982d..3afec6e04729e 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -21,7 +21,10 @@ import { resolveMarkdownFileLinkTarget, } from "./markdownLinks.ts"; import { upgradeLegacyContextMessage } from "./composerContextLegacy.ts"; -import { parseComposerContextHref } from "./composerContextReferences.ts"; +import { + isAttachmentOnlyBootstrapPrompt, + parseComposerContextHref, +} from "./composerContextReferences.ts"; import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts"; import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts"; import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; @@ -216,6 +219,8 @@ export function searchableMessageSegments( skills: readonly InlineSkill[] = [], ): readonly string[] | null { if (message.role === "user") { + // Clients hide this prompt, so find must not match text nobody can see. + if (isAttachmentOnlyBootstrapPrompt(message.text)) return []; const text = message.context ? message.text : upgradeLegacyContextMessage(message.text).text; return markdownThreadFindText(text, true, cwd, skills); } 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, + ), ); } 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.