diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index d7ebb1a0790c..2ff10b84fd3a 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -1,21 +1,10 @@ -// Decides which CI jobs a change needs. Prints `key=value` lines for -// $GITHUB_OUTPUT; a job runs unless its key is "false". -// -// node ci-changes.cjs --paths gate by the listed paths -// node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint -// node ci-changes.cjs changed paths unknown: run everything -// -// scripts/check-changed.ts requires the same decisions so local runs match CI. -// -// Every changed path maps to workspace packages, which then pull in their -// dependents from package.json. A path this file does not recognize runs -// everything, so a new root file or directory can only cost minutes. +// Unknown paths run every job; recognized packages pull in their dependents. +// scripts/check-changed.ts shares these decisions so local checks match CI. const fs = require("node:fs"); const path = require("node:path"); const root = path.resolve(__dirname, "../.."); -// Never read by a build, typecheck, or test. Lint still formats them. const INERT = [ /^docs\//, /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//, @@ -23,15 +12,13 @@ const INERT = [ /^LICENSE$/, ]; -// Run everything: the root vite.config.ts loads the first as setup for every -// package's tests, and the vendored mobile tarballs are installed dependencies. +// Shared test setup and vendored mobile dependencies affect every package. const RUNS_EVERYTHING = [ /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, /^apps\/mobile\/deps\//, ]; -// Paths outside a package, or files inside one that other packages read -// through relative imports rather than a package.json dependency. +// Includes cross-package reads absent from package.json dependencies. const EXTRA_OWNERS = [ // Vite configs and tsconfigs of these apps import scripts/lib directly. [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], @@ -43,9 +30,7 @@ const EXTRA_OWNERS = [ ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; -// Tests that read another package's files without declaring it. The scripts -// tests read app manifests, assets, and sources (cursor-sdk-packaging, -// third-party-licenses, brand-assets, build-desktop-artifact). +// These tests read other packages without declaring dependencies. const TEST_ONLY_DEPS = { "@t3tools/provider-pi": ["@t3tools/client-runtime"], "@t3tools/scripts": [ @@ -56,17 +41,12 @@ const TEST_ONLY_DEPS = { "@t3tools/provider-cursor", ], }; -// Declared dependencies that only feed a build step: the server bundles the -// built web app (apps/server/vite.config.ts) but never imports it. +// The server bundles the web build without importing it. const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; -// Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule -// configuration that decides how it lints them, plus the root package.json -// that defines the lint:mobile command. const MOBILE_NATIVE = /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; -// Jobs that test every package except these, which have jobs of their own. const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); function readWorkspace() { @@ -108,8 +88,7 @@ function readWorkspace() { return packages; } -// Returns the packages a path touches, [] for an inert path, or null when the -// path is not understood and everything must run. +// [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { if (INERT.some((pattern) => pattern.test(file))) return []; // A manifest can change dependencies or versions other packages read. @@ -148,7 +127,6 @@ function decide(files, packages, full) { if (everything) { affected = all; } else { - // Add dependents until nothing changes. let grew = true; while (grew) { grew = false; diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 90b368b2aa03..d017fc907f12 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -1,7 +1,4 @@ -// Publishes closed-testing betas, or downloads the internal APK for a normal -// nightly release. Play generates and signs the APK from the -// uploaded bundle with the app signing key, so it carries the same signature -// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml. +// Play signs generated APKs with the app signing key, matching Play Store installs. const { execFileSync } = require("node:child_process"); const crypto = require("node:crypto"); const fs = require("node:fs"); @@ -50,10 +47,7 @@ async function play(token, method, url) { return response; } -// Highest version code the track serves, with its release name. Play names a -// release after the bundle's versionName unless the uploader sets one. This -// endpoint reads without opening an edit; an edit would invalidate any open -// EAS Submit edit made with the same service account. +// Opening an edit would invalidate any EAS Submit edit using the same service account. async function newestTrackBuild(token, appUrl, trackName) { const { releases = [] } = await ( await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`) @@ -82,8 +76,7 @@ function readRelease(tag) { } } -// Download to a temporary directory so a failed request cannot leave a partial -// APK among the release assets. +// Stage downloads so failed requests cannot leave partial release assets. async function downloadApk({ token, appUrl, build, apkName }) { const generated = await ( await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`) diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py index 0b3a53285916..1730ee5c8d75 100644 --- a/.github/scripts/stage-preview-bundle.py +++ b/.github/scripts/stage-preview-bundle.py @@ -12,8 +12,7 @@ "server/dist/client/index.html", "desktop/dist-electron/main.cjs", } -# The current bundle is about 32 MiB compressed. Bound extraction on the -# trusted runner even when the PR replaces the uploader entirely. +# Bound trusted-runner extraction even when a PR replaces the uploader. MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 MAX_ENTRIES = 50_000 @@ -58,9 +57,7 @@ def stage_bundle(archive: Path, destination: Path): files.add(name) if not REQUIRED_FILES <= files: raise ValueError("Preview bundle is missing required entry points") - # Validate all names before writing anything. This is a fresh directory - # outside the checkout; neither pre-existing links nor trusted files - # can be followed or overwritten. ZIP permissions are never restored. + # Validate before writing into a fresh directory so existing links/files cannot be followed or overwritten. destination.mkdir(parents=True, exist_ok=False) for entry in entries: target = destination / entry.filename diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 5ed6a6ff498a..ca57769ed490 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -294,8 +294,7 @@ async function resolve({ github, context, core }) { const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); const sourceArtifact = findResultArtifact(sourceArtifacts); - // CI requires the artifact whenever the server tests run, so a passing run - // without one skipped them because the PR cannot affect the server. + // Successful server tests require this artifact; its absence means CI skipped them. if (!sourceArtifact && source.conclusion === "success") { core.info("CI skipped the server tests for this change; nothing to report."); core.setOutput("publish", "false"); diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md index 0541e7407c0c..66e5434c5ed1 100644 --- a/.macroscope/ignore.md +++ b/.macroscope/ignore.md @@ -255,3 +255,27 @@ ENV/** # (see scripts/lib/reference-repos.ts). Nothing imports from them; findings # there belong upstream. .repos/** + +# Generated code. Review its source (the script, GraphQL document, schema +# definition, or theme) instead; review agents can still read these on demand. +**/*.generated.ts +**/*.graphql.ts +apps/server/schema/expo.graphql +packages/source-control-github/schema/github.graphql +apps/mobile/generated-uniwind-* +apps/mobile/clerk-theme.json +apps/server/src/testUtils/shardWeights.json +infra/relay/migrations/**/snapshot.json + +# Provider transcripts recorded from real CLI runs for the orchestration replay +# tests. Each scenario's input.ts and *_output.ts assertions stay reviewed. +**/*.ndjson + +# Ghostty build output vendored from upstream. +apps/mobile/modules/t3-terminal/Vendor/** +native/libghostty-vt/include/** + +# @pierre/highlights compiled to C by wasm2c, plus the wasm2c runtime and simde +# it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review +# that script and cpp/t3_highlights.c instead. +apps/mobile/modules/t3-highlights/Vendor/** diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index e79d0e71dc86..a7b10f64404a 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -1,8 +1,5 @@ -// Windows WSL dev mode runs the server under Linux from the same node_modules -// as the Windows client, so Windows installs also need Linux x64 glibc native -// modules. Other platforms install only their own binaries; setting this -// everywhere added ~2.2 GB of foreign binaries to every macOS install. -// Release scripts stage their own installs with explicit architectures. +// Windows installs need Linux x64 glibc binaries because WSL shares node_modules. +// Keep other platforms native; release scripts set their own architectures. module.exports = { hooks: { // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside diff --git a/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts index cb2954120928..3484d6696953 100644 --- a/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts +++ b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts @@ -48,7 +48,7 @@ export interface NodeServeHandler { export interface NodeServeEntryOptions { /** * JS expression resolving the on-disk client-assets directory - * (typically `fileURLToPath(new URL("../client/", import.meta.url))`). + * (typically `fileURLToPath(new URL("../shared/", import.meta.url))`). * Omit to skip static-file serving (Next.js handles its own assets). */ readonly clientDirExpression?: string | undefined; diff --git a/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts index 66667e6fb44c..01147c61d530 100644 --- a/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts +++ b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts @@ -28,14 +28,14 @@ describe("relativeClientDirExpression", () => { "/project/dist/server/serve-node.mjs", "/project/dist/client", ), - ).toBe(`fileURLToPath(new URL("../client/", import.meta.url))`); + ).toBe(`fileURLToPath(new URL("../shared/", import.meta.url))`); }); }); describe("makeNodeServeEntrySource", () => { it("listens on PORT, answers /health, serves static files, then the fetch handler", () => { const source = makeNodeServeEntrySource({ - clientDirExpression: `fileURLToPath(new URL("../client/", import.meta.url))`, + clientDirExpression: `fileURLToPath(new URL("../shared/", import.meta.url))`, handler: { kind: "fetch", imports: `import { handler } from "./index.mjs";`, @@ -131,7 +131,7 @@ describe("makeNodeServeEntrySource", () => { it("does not map GET / onto public/index.html (SSR home stays on the handler)", () => { const source = makeNodeServeEntrySource({ - clientDirExpression: `fileURLToPath(new URL("../client/", import.meta.url))`, + clientDirExpression: `fileURLToPath(new URL("../shared/", import.meta.url))`, handler: { kind: "node", imports: `import { handler } from "./index.mjs";`, diff --git a/AGENTS.md b/AGENTS.md index 8a842f9cdd4a..b3ce2abb2c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ Most code changes do not need an internal documentation change. Agents can read - `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out. - Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index. -- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. +- A local reason the code can't show goes in a short nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. - When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist. - `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with. - Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page. @@ -194,7 +194,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ Named `Effect.fn` creates a span; the unnamed form does not. Preserve existing span names and options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. -- Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases. +- Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 99bbcd26f892..23610f3280cb 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -391,9 +391,7 @@ function buildMacLauncher(electronBinaryPath) { JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { if (isDevelopment) { - // The launcher also handles protocol activations outside the dev runner, - // so refresh its fallback environment on every launch. Never let a value - // captured by an older parent app override the live dev-runner environment. + // Protocol activations bypass the dev runner, so refresh the fallback environment on every launch. writeDevelopmentEnvironmentScript(); if (writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath)) { signMacLauncherBundle(targetAppBundlePath); @@ -404,10 +402,7 @@ function buildMacLauncher(electronBinaryPath) { } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); - // verbatimSymlinks keeps the framework's relative symlinks intact - // (e.g. Resources -> Versions/Current/Resources). Without it cpSync - // rewrites them to absolute paths into node_modules, which escape the - // bundle and crash sandboxed helper processes (icudtl.dat not found). + // Without verbatimSymlinks, cpSync makes framework symlinks point outside the bundle and crashes helpers. NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true, @@ -419,10 +414,7 @@ function buildMacLauncher(electronBinaryPath) { ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - // Launching the node_modules copy makes macOS associate the process (and Dock label) with - // Electron.app even though this bundle's Info.plist has the T3 Code name. Its conventional - // executable name also keeps Electron's default-app runtime in development mode instead of - // making app.isPackaged report true. + // Electron.app's executable name keeps default-app mode active without making app.isPackaged true. writeDevelopmentEnvironmentScript(); writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7dffe9fe3b1f..f57e709bca7a 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -339,11 +339,7 @@ const scopedProgram = Effect.scoped( const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; yield* Effect.addFinalizer(() => - // Stop every backend in the pool, not just the primary. The - // electronApp.quit() path can race ahead of the layer-scope - // cascade, so leaving the WSL instance for its parent scope - // finalizer means it gets hard-killed by the OS instead of - // receiving SIGTERM + grace. + // Quit can outrun layer finalizers; stop pooled backends here so they receive SIGTERM before OS teardown. stopAllPoolInstances().pipe( Effect.ensuring(rendererHistory.shutdown), Effect.ensuring(shutdown.markComplete), diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 688bd789572f..05d5a3254a84 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -186,10 +186,7 @@ export async function startDesktopAppControlServer(input: { server.listen(address); }); - // Closing a Unix socket server unlinks the path it was bound to, even when - // another app's socket lives there now. Bind a staging path and move it onto - // the address instead, so a later close only unlinks the staging path, which - // is already gone. + // Closing unlinks the original bind path, even if another app replaced it. Bind a staging path first. const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); @@ -213,7 +210,6 @@ export async function startDesktopAppControlServer(input: { let server: NodeNet.Server; let inode: number | null = null; if (input.directory === null) { - // Named pipes close with the app that owns them, so no other app can remove this one. server = await listen(input.address); } else { await prepareUnixDirectory({ directory: input.directory, userId: input.userId }); @@ -222,10 +218,10 @@ export async function startDesktopAppControlServer(input: { let closed = false; const reclaimOnce = async () => { - // Never replace a socket that exists, so two apps cannot trade the path back and forth. + // Replacing an existing socket would let two apps trade the path indefinitely. if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return; if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) { - // A watch follows the directory's inode, so a recreated directory needs a new one. + // Directory watches follow inodes, so recreate the watch after directory replacement. watchDirectory(input.directory); } const next = await bindUnix(input.directory, "claim-free").catch( @@ -269,7 +265,7 @@ export async function startDesktopAppControlServer(input: { close: async () => { if (closed) return; closed = true; - // A running reclaim can replace the watcher, so close the watcher after it. + // Reclaim can replace the watcher; close it afterward. await pendingReclaim; watcher?.close(); for (const socket of sockets) socket.destroy(); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 395c06141d64..5fd6983c6668 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -38,7 +38,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -91,9 +91,7 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - // Electron removes spaces from this name to build the native User-Agent - // product token, but leaves parentheses intact. Keep the runtime name valid - // without rewriting preview sessions (which breaks Turnstile, #7110). + // Electron strips spaces but keeps parentheses in its User-Agent token, breaking Turnstile in preview sessions. yield* electronApp.setName( `${environment.branding.baseName} ${environment.branding.stageLabel}`, ); @@ -107,9 +105,7 @@ export const make = Effect.gen(function* () { yield* electronApp.setAppUserModelId(environment.appUserModelId); } - // Unpackaged runs only. A packaged bundle already carries its icon in - // Info.plist, so setting the dock tile again changes nothing except to - // overwrite a custom icon the user attached to the app themselves. + // Packaged bundles carry their icon in Info.plist; setting it again overwrites the user's custom icon. if (environment.platform === "darwin" && !environment.isPackaged) { const iconPaths = yield* assets.iconPaths; yield* Option.match(iconPaths.png, { diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 505b48194cf7..3e0d753d1bfb 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -95,7 +95,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; @@ -105,14 +105,12 @@ export const make = Effect.gen(function* () { const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); - // T3 owns the profile-scoped lock on every platform; Clerk skips it on macOS. - // Quit is asynchronous, so interrupt before the application runtime is built. + // Clerk skips the profile lock on macOS. Interrupt before runtime construction because quit is asynchronous. if (!(yield* electronApp.requestSingleInstanceLock)) { yield* electronApp.quit; return yield* Effect.interrupt; } - // Retain launch arguments while asynchronous startup builds the application runtime. const pendingSecondInstances: Array = []; let handleSecondInstance = (argv: readonly string[]) => { pendingSecondInstances.push(argv); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index c9a79a81d3b5..db56aa3c83b0 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -83,7 +83,6 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; -// The machine's own PATH may already hold a t3; each test sets the PATH it means. const machinePath = process.env.PATH; beforeEach(() => { process.env.PATH = ""; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 294f8a8b984e..95d844e60212 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -28,14 +28,8 @@ const pathEntries = (value: string | undefined, separator: string) => const sameWindowsPath = (left: string, right: string) => left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); -/** - * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping - * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for - * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` - * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it - * opens see the change. A failed read exits nonzero rather than reading as - * empty, so a write never replaces the user's PATH wholesale. - */ +// Preserve REG_EXPAND_SZ and %VAR% expansion; notify Explorer with WM_SETTINGCHANGE. +// Failed reads must exit nonzero to prevent replacing PATH wholesale. const WINDOWS_USER_PATH_SCRIPT = ` $ErrorActionPreference = 'Stop' if ($env:T3_SET -eq '1') { @@ -64,7 +58,7 @@ export class DesktopCliCommand extends Context.Service< } >()("@t3tools/desktop/app/DesktopCliCommand") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index e043b4d16d8d..50fcb08d9c64 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -81,7 +81,6 @@ describe("renderCliShim", () => { executable: NodePath.join(root, "app"), entry: "/bin.mjs", }); - // sudo clears the environment, so the launcher supplies its own. expect(run(shim, []).stdout).toContain(`home=${root}/home`); expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index a9d9f04c4e82..3d4b94abff46 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -6,8 +6,7 @@ import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// An AppImage mounts somewhere new each run, so its launcher mounts the -// AppImage itself instead of pointing into it. +// AppImage mounts change each run; the launcher must mount the image itself. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; @@ -26,12 +25,7 @@ export type CliShimTarget = | { readonly kind: "direct"; readonly executable: string; readonly entry: string } | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; -/** - * Electron runs the server as plain Node with `ELECTRON_RUN_AS_NODE`, which - * reads the entry from inside the asar archive. The launcher's own path and the - * app's T3 home are written in, so the command the server shows is absolute and - * `sudo`, which clears the environment, still runs against this install's home. - */ +/** Embed this install's home and executable path so `sudo`, which clears the environment, uses the same install. */ export const renderCliShim = (input: { readonly target: CliShimTarget; readonly shimPath: string; @@ -48,9 +42,7 @@ export const renderCliShim = (input: { `rem ${MARKER}`, // Delayed expansion would rewrite `!name!` in paths and arguments. "setlocal EnableExtensions DisableDelayedExpansion", - // Only a non-ASCII path needs the console switched to UTF-8 to read it; a - // Ctrl-C that ends the batch skips the restore, so the switch is left out - // for the common all-ASCII install. + // Ctrl-C skips codepage restoration, so switch to UTF-8 only for non-ASCII install paths. ...(utf8 ? [ `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, @@ -97,8 +89,7 @@ export const renderCliShim = (input: { ...header, // Extract-and-run would make the runtime start the app instead of mounting it. "unset APPIMAGE_EXTRACT_AND_RUN", - // The AppImage runtime prints its mount point, then stays mounted until - // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + // The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C. "work=$(mktemp -d) || exit 1", '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', "mounter=$!", diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 3e9012d38026..5780dfb9239d 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -56,6 +56,7 @@ export const DesktopConfig = Config.all({ otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( Config.withDefault("http/json"), ), + desktopLauncherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 0015e642672c..00a355bf4961 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -86,8 +86,7 @@ const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; const featureListNames = (value: string | null): ReadonlyArray => (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []); -// A stale HYPRLAND_INSTANCE_SIGNATURE is often inherited into other desktops, so only -// trust the session's desktop names. +// HYPRLAND_INSTANCE_SIGNATURE can be stale; trust only the session's desktop names. const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean => (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "") .split(":") @@ -103,12 +102,8 @@ const HyprctlVersionJson = fromLenientJson( ); const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson); -// Hyprland 0.48.0 added wp-color-manager-v1 (hyprwm/Hyprland#9444) advertising -// FEATURE_SET_PRIMARIES while rejecting set_primaries outside debug mode, which Chromium -// treats as a fatal protocol error. 0.51.0 accepts them (hyprwm/Hyprland#11499). -// Only stock builds of these releases count. Matching the commit as well as the tag -// keeps later development builds that still report an old VERSION and tag (Nix -// synthesizes `v${VERSION}`) on the default. +// Hyprland 0.48.0 rejects advertised set_primaries (hyprwm/Hyprland#9444); 0.51.0 accepts it (hyprwm/Hyprland#11499). +// Check commits because dev and Nix builds can retain release versions and tags. const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap = new Map([ ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"], ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"], diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 4e0283282016..e3cce0fc9287 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -77,6 +77,7 @@ export class DesktopEnvironment extends Context.Service< readonly linuxDesktopEntryName: string; readonly linuxWmClass: string; readonly linuxApplicationsDir: string; + readonly desktopLauncherPath: Option.Option; readonly appImagePath: Option.Option; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; @@ -239,6 +240,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment), linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", linuxApplicationsDir, + desktopLauncherPath: config.desktopLauncherPath, appImagePath: config.appImagePath, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index 122aa3393f9c..90043d9398a2 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -10,10 +10,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -/** - * The V1 profile is only read, never opened by Chromium, so this works while V1 - * is still running. - */ +/** Reads the V1 profile without opening it in Chromium, even while V1 runs. */ export class DesktopLegacyLocalStorage extends Context.Service< DesktopLegacyLocalStorage, { diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 7512d2bc1741..93347949e00f 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -208,7 +208,7 @@ function quitFromSignal( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 854f43c5623c..feedbbd58007 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -31,6 +31,7 @@ const makeEnvironment = (path: Path.Path, overrides: Record = { linuxDesktopEntryName: "com.t3tools.T3Code.desktop", linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", + desktopLauncherPath: Option.none(), appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), path, ...overrides, @@ -228,6 +229,18 @@ describe("DesktopLinuxUrlHandler", () => { }); }); + it.effect("launches a distribution package through its configured launcher", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded, { + environment: { desktopLauncherPath: Option.some("/opt/T3 Code/t3code") }, + }); + + assert.include(recorded.files[0]?.content, 'Exec="/opt/T3 Code/t3code" %U'); + }); + }); + it.effect("does not rewrite the pre-ready entry while the portal can be reading it", () => { const recorded = emptyRecording(); @@ -457,9 +470,7 @@ const themeIconFixture = (source: Uint8Array) => const hicolorDir = path.join(root, "icons", "hicolor"); const appsDir = path.join(hicolorDir, "256x256", "apps"); const cachePath = path.join(hicolorDir, "icon-theme.cache"); - // A fixed time in the past, in whole seconds as fs.utimes expects. const pastSeconds = 1_600_000_000; - // An existing cache that predates any installed icon. const seedCache = Effect.gen(function* () { yield* fileSystem.makeDirectory(appsDir, { recursive: true }); yield* fileSystem.writeFile(cachePath, new Uint8Array([0])); @@ -547,7 +558,6 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { commands: [], onIconCacheRefresh: fixture.rewriteCache, }; - // A cache stamped later than the new icons, as after a clock correction. const future = fixture.pastSeconds + 10 * 365 * 24 * 60 * 60; yield* fileSystem.utimes(fixture.cachePath, future, future); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e03418964e33..dd38acbc3894 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -12,12 +12,7 @@ import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// Linux ships as an AppImage, so the .desktop entry users end up with is -// created by whatever integration tool they use (AppImageLauncher names it -// appimagekit_-….desktop) and its filename is not under our control. -// Electron's app.setAsDefaultProtocolClient resolves the desktop id from -// setDesktopName, which cannot match those files — so the browser keeps -// prompting "Choose an application" for every OAuth callback. +// AppImage integrators choose the .desktop filename, so setDesktopName cannot identify it for protocol registration. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -62,9 +57,7 @@ const escapeDesktopEntryString = (value: string): string => .replaceAll("\r", "\\r") .replaceAll("\t", "\\t"); -// Exec values are unescaped twice by implementations: first the general -// string-value rules, then the Exec quoting rules — so writing composes the -// layers in reverse. +// Exec values undergo general unescaping then Exec unquoting; escape in reverse order. export function escapeDesktopEntryExecArgument(value: string): string { const quoted = value .replaceAll("\\", () => "\\\\") @@ -103,9 +96,7 @@ export function renderUrlHandlerDesktopEntry(input: { ].join("\n"); } -// Some docks and bars look the Wayland app id up in the icon theme when no -// visible desktop entry matches the window, for example an AppImage run without -// integration. Theme lookups are case-sensitive, so install both spellings. +// Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive. const LINUX_THEME_ICON_SIZE = "256x256"; function linuxThemeIconNames(desktopEntryName: string): readonly string[] { @@ -123,7 +114,7 @@ export class DesktopLinuxUrlHandler extends Context.Service< } >()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; @@ -141,17 +132,21 @@ export const make = Effect.gen(function* () { const themeIconDir = environment.path.join(hicolorDir, LINUX_THEME_ICON_SIZE, "apps"); const writeDesktopEntry = Effect.gen(function* () { + // Distribution packages that run on a shared Electron set + // T3CODE_DESKTOP_LAUNCHER_PATH, because process.execPath is that Electron. // Inside the mounted AppImage, process.execPath points at a transient // /tmp/.mount_* path — the handler must launch the AppImage itself. - const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); + const execTarget = environment.desktopLauncherPath.pipe( + Option.orElse(() => environment.appImagePath), + Option.getOrElse(() => process.execPath), + ); const content = renderUrlHandlerDesktopEntry({ displayName: environment.displayName, execTarget, scheme, ...(environment.isPackaged ? { iconPath } : {}), }); - // Pre-ready setup normally wrote this already. Avoid truncating a valid - // entry while the portal may be reading it during startup. + // Avoid truncating the pre-ready entry while the portal may be reading it. const existing = yield* fileSystem .readFileString(desktopEntryPath) .pipe(Effect.orElseSucceed(() => null)); @@ -235,10 +230,8 @@ export const make = Effect.gen(function* () { yield* fileSystem.writeFile(target, icon); changed = true; } - // Only keep an existing cache current. Creating one would leave GTK reading - // a cache that other tools installing user icons do not update. An icon - // newer than the cache also retries a refresh that failed on an earlier - // launch. + // Creating a cache would leave GTK reading stale icons from other installers. + // A newer icon retries a refresh that failed on an earlier launch. const cachePath = environment.path.join(hicolorDir, "icon-theme.cache"); const cachedAt = yield* modifiedAt(cachePath).pipe(Effect.option); if (Option.isNone(cachedAt)) return; @@ -313,10 +306,7 @@ export const make = Effect.gen(function* () { yield* installThemeIcons; yield* updateDesktopDatabase.pipe( - // Some MIME implementations, including GIO, use mimeinfo.cache to verify - // that a desktop entry is associated with a scheme. Cache refresh is - // independently best-effort so a missing update-desktop-database executable - // does not prevent xdg-mime from recording the requested default. + // GIO verifies scheme associations through mimeinfo.cache. A missing cache updater must not block xdg-mime. Effect.catch((error) => logWarning("desktop MIME cache refresh failed", { applicationsDir: environment.linuxApplicationsDir, diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index e8240993e5be..cb5271ca98bc 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -429,8 +429,7 @@ const backendLogFilePathForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, ): string => { - // Primary keeps the historical "server-child.log" path so ops scripts - // and packaged-build log inspection still find it where it always lived. + // Keep the primary log path stable for ops scripts and packaged-build inspection. if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { return environment.path.join(environment.logDir, "server-child.log"); } @@ -438,8 +437,7 @@ const backendLogFilePathForInstance = ( return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; -// Cacheable by resolved file path so two ids that sanitize to the same filename -// share a single RotatingLogFileWriter (no race on currentSize tracking). +// IDs sharing a sanitized filename must share a writer to avoid currentSize races. const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, @@ -616,16 +614,7 @@ const layerTelemetry = Layer.unwrap( }, }; - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the main process wants has to be named in this one call. - // Splitting the OTLP logger back out into a layer of its own silently - // drops either it or the console output. - // Swapping `Logger.tracerLogger` out for the OTLP logger matches the - // server: both reach a collector, but the tracer logger covers only - // messages logged inside a recorded span and files them under traces, - // while the OTLP logger carries every message as a log record stamped - // with its trace and span ids. Keeping both would export every in-span - // message twice. + // Logger.layer replaces the logger set. tracerLogger would duplicate OTLP in-span messages. const layerLogger = Logger.layer( endpoints.logs === undefined ? [Logger.consolePretty(), Logger.tracerLogger] diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 5af13f94413f..6a7473e2f1b1 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -32,9 +32,7 @@ const syncFs = (method: string, path: string, run: () => A) => }), }); -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = FileSystem.makeNoop({ exists: (path) => syncFs("exists", path, () => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 143a6e5fd87c..f2b176ee3761 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -166,6 +166,28 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); + it.effect("points the early portal entry at a configured distribution launcher", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); + vi.stubEnv("T3CODE_DESKTOP_LAUNCHER_PATH", " /opt/T3 Code/t3code "); + getSwitchValueMock.mockReturnValue(""); + + return Effect.scoped( + Effect.gen(function* () { + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + ), + ); + const entry = writeFileSyncMock.mock.calls.find( + ([path]) => path === "/xdg/applications/com.t3tools.T3Code.desktop", + )?.[1]; + assert.include(entry, 'Exec="/opt/T3 Code/t3code" %U'); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 58670439a525..8bef7366e378 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -49,7 +49,7 @@ export class DesktopPreReadyElectronOptions extends Context.Service< } >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; @@ -109,15 +109,17 @@ export const make = Effect.gen(function* () { isDevelopment: linux.isDevelopment, appVersion: Electron.app.getVersion(), }).displayName, - execTarget: process.env.APPIMAGE?.trim() || process.execPath, + execTarget: + process.env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || + process.env.APPIMAGE?.trim() || + process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), "utf8", ); } catch {} - // Chromium caches its portal registration during startup. Set the identity - // before any asynchronous work can initialize it with Electron's default. + // Chromium caches portal identity; set it before any asynchronous initialization. Electron.app.setDesktopName(linux.linuxDesktopEntryName); Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { @@ -146,8 +148,7 @@ export const make = Effect.gen(function* () { }); }).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); -// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot -// observe app readiness before scheme privileges and command-line switches exist. +// Complete scheme privileges and switches before later layers can observe app readiness. export const layer = Layer.mergeAll( ElectronProtocol.layerSchemePrivileges, Layer.effect(DesktopPreReadyElectronOptions, make), diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts index 98de2e91eb06..51beceb9e463 100644 --- a/apps/desktop/src/app/chromiumLocalStorage.ts +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -4,12 +4,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -/** - * It reads the database files directly and never takes LevelDB's lock, so it - * works while another app version has the profile open. Live files come from - * the MANIFEST that CURRENT names, and the newest sequence number wins, which - * is how LevelDB itself resolves overwrites and deletions. - */ +/** Read without LevelDB's lock so another app can keep the profile open. */ const LOG_BLOCK_SIZE = 32 * 1024; const LOG_HEADER_SIZE = 7; @@ -145,11 +140,7 @@ function decompressSnappy(input: Uint8Array): Uint8Array { return output; } -/** - * Like LevelDB's own reader, a corrupt record or zero padding skips the rest of - * its block, since a reused log keeps appending after a torn write, and a - * record cut off by the end of the file ends the log. - */ +/** Corruption skips the remaining block; a truncated final record ends the log, matching LevelDB recovery. */ function readLogRecords(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; let pending: Uint8Array[] | null = null; @@ -422,9 +413,7 @@ export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage. }).pipe(wrap); } - // A flush while another process has the database open can move entries - // from a log we read into a table our MANIFEST copy predates. Fail so the - // caller retries rather than import a snapshot missing those entries. + // Concurrent flushes can move entries beyond this MANIFEST snapshot; detect changes so the caller retries. const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap); if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 2a5f50779af6..ca69f5d02589 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -81,8 +81,7 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. +// Windows forwards these variables into WSL only through WSLENV. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -92,9 +91,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", - // Forwarded without a WSLENV flag, so the values arrive untranslated. The - // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs - // travel as variables to keep winning inside the distro as they do on Windows. + // Untranslated T3 URLs must override bootstrap endpoints inside WSL, as they do on Windows. "T3CODE_OTLP_TRACES_URL", "T3CODE_OTLP_METRICS_URL", "T3CODE_OTLP_LOGS_URL", @@ -146,10 +143,7 @@ const mergeWslEnv = ( const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); - // Preserve the user's WSLENV exactly as Windows handed it to us — empty - // "::" segments and duplicate entries are harmless no-ops to WSL and not - // ours to normalize — and only append the secrets we need to forward - // across the wsl.exe boundary. + // Preserve empty segments and duplicates in the user's WSLENV; only append required variables. const parts = [existing, ...additions].filter((part) => part.length > 0); return parts.length > 0 ? parts.join(":") : undefined; }; @@ -254,10 +248,7 @@ type WslPreflightRuntime = } | { readonly kind: "node-script"; - // Absolute path to the node binary the preflight validated after the - // shared remote resolver repaired PATH. The launch must use this exact - // path so it doesn't fall through to a different/old node than the one - // node-pty was probed with. + // Launch the exact Node path probed with node-pty, even if later PATH lookup would resolve another version. readonly nodePath: string; readonly linuxEntryPath: string; }; @@ -398,8 +389,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime supplies its own Node and node-pty. Provider PATH - // discovery must not require either dependency for runtime readiness. + // Staged runtimes bundle Node and node-pty; provider PATH discovery must not require them. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; @@ -439,10 +429,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f nodePtyOptions, ); if (!nodePtyResult.ok) { - // Substituting the staged verdict for a transient mounted failure would - // turn a retryable failure into a fatal one, ending the WSL attempt (and, - // in wsl-only mode, persisting Windows) before the slow /mnt path had a - // chance to answer and clear the bad cache. + // Only substitute a staged failure for a fatal mounted failure; transient /mnt failures must remain retryable. return stagedFailure && nodePtyResult.fatal ? failedStaged(stagedFailure) : failedNodePty(nodePtyResult); @@ -465,16 +452,12 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); -// In WSL2 mirrored mode the distro's eth0 IP equals the host's, which is the -// signature we use to detect that mode and switch the renderer URL to loopback. +// In WSL2 mirrored mode, eth0 matches a host IP; use loopback for the renderer. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { if (!list) continue; for (const entry of list) { - // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on - // the Node build Electron ships (41 / Node 22, verified), but some Node - // builds report the numeric 4. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -521,7 +504,6 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // Failed Windows probes leave server hydration enabled for another attempt. shellEnvironmentPrepared: input.shellEnvironmentPrepared, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, @@ -538,10 +520,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv return { executablePath: process.execPath, - // Packaged builds only, so a dev instance never shares the cache with the - // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, - // so the setting does not leak into the provider and terminal processes - // the backend starts. + // Packaged builds have a separate cache; --require prevents cache settings leaking to provider subprocesses. args: [ ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), environment.backendEntryPath, @@ -582,25 +561,18 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; - // wslhost forwarding is unreliable on some Windows hosts: the desktop's - // readiness probe and the renderer's saved-env-style fetch both saw "Failed - // to fetch" when the backend only bound to 127.0.0.1 inside WSL. Binding to - // 0.0.0.0 plus advertising the WSL IP as the renderer-visible URL avoids that - // dependency. + // wslhost loopback forwarding is unreliable on some Windows hosts; advertise the WSL IP instead. const wslBindHost = "0.0.0.0"; const bootstrap = { mode: "desktop" as const, noBrowser: true, port: input.port, - // Omit t3Home so the Linux backend uses its own home dir instead of - // the Windows-side baseDir (which would be a /mnt/c path and share - // the SQLite file with the primary). + // Omit t3Home to keep the Linux backend from sharing the primary's Windows SQLite file. host: wslBindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // PortSchema rejects 0, so when tailscale serve is disabled we still need a - // valid number in this slot. + // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, ...buildObservabilityFragment(input.observabilitySettings), @@ -641,9 +613,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl allowBuild: !environment.isPackaged, }); - // Every operation after preflight uses the same concrete distro. In - // default-tracking mode this closes the race where the system default - // changes between probing and spawning the backend. + // Pin the preflight distro so a default change cannot race backend startup. const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; @@ -686,8 +656,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...forwardedEnv, ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), }, - // env is already a complete process.env minus T3CODE_HOME; pass it - // verbatim instead of letting the spawner re-merge process.env on top. + // env already excludes T3CODE_HOME; merging process.env would restore it. extendEnv: false, bootstrap, bootstrapDelivery: "stdin" as const, @@ -696,8 +665,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...(runningDistro !== null ? { runningDistro } : {}), }; - // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. + // Explicit --dev-url keeps WSL state in dev/ rather than userdata/. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], @@ -717,10 +685,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; } - // The WSL server spawns commands its providers reference by name — `npm`/`npx` - // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those - // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on - // the process PATH, so `npm install -g ...` fails with NotFound. + // wsl.exe --exec omits the login-shell PATH needed to find provider CLIs and npm. const runtime = preflight.runtime; const launchPath = runtime.kind === "executable" @@ -748,7 +713,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; @@ -758,12 +723,7 @@ export const make = Effect.gen(function* () { const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; - // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. - // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can - // resolve concurrently; with a plain Ref both could observe None, generate - // distinct secrets, and one would overwrite the other — leaving the two - // backends deriving mismatched tokens and breaking the shared-token invariant - // the renderer relies on. + // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { @@ -834,18 +794,11 @@ export const make = Effect.gen(function* () { ); }); - // Dispatch happens at resolve time so toggling wsl-only between restarts - // is picked up on the next start cycle (the pool's primary instance is - // created once at layer init, but configResolve fires on each restart). + // Resolve on each restart so toggling wsl-only takes effect without recreating the pool. const describePrimary = Effect.gen(function* () { const persistedSettings = yield* settings.get; const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; - // Only honor wsl-only when WSL is actually usable. If the user - // persisted wsl-only but WSL has since become unavailable (wsl.exe - // removed, no distro), fall back to the Windows primary instead of - // looping forever on preflight failures: the Connections backend - // control is hidden while WSL is unavailable, so a stuck WSL primary - // would otherwise leave no in-app way back to Windows. + // When WSL is unavailable its controls are hidden; fall back to Windows to keep recovery possible. const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b4057f4d0194..007f7369365a 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -37,8 +37,7 @@ const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); -// Same as the service launcher. A killed backend leaves its agents running, -// and a busy one needs a few seconds to stop every provider session. +// Match the service launcher's grace period so backends can stop provider sessions. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; @@ -63,9 +62,7 @@ export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; export interface DesktopBackendStartConfig extends BackendProcessContext { readonly args: ReadonlyArray; readonly env: Record; - // When true the spawner merges the desktop process.env on top of `env`; - // when false `env` is passed verbatim. WSL mode opts out so a leaking - // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + /** False passes env verbatim, preventing the Windows T3CODE_HOME from leaking into WSL. */ readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; @@ -234,11 +231,7 @@ export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly configResolve: Effect.Effect; - // Receives the *resolved* httpBaseUrl of the run that just became - // ready. The window service uses this to decide what URL to load - // (the WSL backend reports its distro IP, the Windows backend reports - // 127.0.0.1). Splitting this off from configResolve avoids races - // between "fired onReady" and "currentConfig already advanced". + /** Receives the ready run's resolved URL; reading current config could race a subsequent restart. */ readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; @@ -431,16 +424,12 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( cwd: options.cwd, env: options.env, extendEnv: options.extendEnv, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", stdout: options.captureOutput ? "pipe" : "inherit", stderr: options.captureOutput ? "pipe" : "inherit", killSignal: "SIGTERM", forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - // wsl.exe drops additional file descriptors when forwarding to the Linux - // side, so the WSL spawn path delivers the bootstrap envelope via stdin - // (`--bootstrap-fd 0`) instead. + // wsl.exe drops extra file descriptors; deliver its bootstrap via stdin. ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}), }); @@ -542,12 +531,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - // Probe readiness in a loop while the backend process is still alive instead - // of giving up after the first budget. A slow cold boot (the WSL bundle - // loading across /mnt/c, or a first launch right after an update) can exceed - // the initial readiness budget while the backend is about to come up moments - // later; a one-shot probe left the app stuck on "Connecting to WSL…" forever - // even though the backend kept running and became healthy. + // Cold WSL boots across /mnt/c can exceed one probe budget; retry while the backend is alive. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => waitForHttpReady({ executablePath: options.executablePath, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index a8576a2b9e99..0583e8df2170 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -96,8 +96,6 @@ describe("DesktopBackendPool", () => { const instance = yield* pool.register({ id, label: Effect.succeed("WSL (Ubuntu)"), - // A transient preflight failure leaves a restart fiber scheduled - // without needing a real child process. configResolve: Effect.succeed({ executablePath: "/electron", args: [], diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 135df79c63ad..7b2f68519076 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -106,16 +106,11 @@ export const layer = Layer.effect( const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; - // Without this, instance scopes are orphaned: they only close via explicit unregister() calls, - // so on app shutdown the WSL backend child process gets hard-killed by the OS instead of - // receiving the graceful SIGTERM + grace period the instance's stop finalizer would otherwise - // run. + // Parent instance scopes to the pool so shutdown runs stop finalizers before the OS kills backends. const layerScope = yield* Scope.Scope; const factoryContext = yield* Effect.context(); - // Fatal configuration failures persist the Windows fallback. Bounded - // transport failures use an in-memory fallback for this launch so the app - // opens without overwriting the user's WSL preference. + // Persist fatal Windows fallbacks; transient transport failures must preserve the user's WSL preference. const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( function* (failure: DesktopBackendManager.PreflightFailure) { const { reason, fatal } = failure; @@ -139,9 +134,8 @@ export const layer = Layer.effect( "WSL backend couldn't start", `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, ); - // Clearing wslBackendEnabled alone would leave a stale wslOnly:true that silently re-traps - // the user in wsl-only mode the next time they enable WSL. If the persisted write fails, - // keep this process recoverable by applying the fallback to in-memory settings. + // Clear wslOnly too so enabling WSL cannot restore a failed mode. + // Keep the in-memory fallback if persistence fails. yield* appSettings.applyWslWindowsFallback.pipe( Effect.catch((error) => logBackendPoolWarning( @@ -158,14 +152,10 @@ export const layer = Layer.effect( const primary = yield* DesktopBackendManager.makeBackendInstance({ id: DesktopBackendManager.PRIMARY_INSTANCE_ID, - // Keep this lazy. The pool layer is initialized before startup loads - // persisted desktop settings, so resolving the primary label here would - // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only - // primaries as Windows. + // Resolve lazily: startup loads persisted settings after pool initialization. label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, - // Window creation errors propagating out of handleBackendReady must not block the readiness - // callback (that would prevent restartAttempt from being reset), so we absorb them here. + // Absorb window errors so readiness can reset restartAttempt. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( Effect.catch((error) => @@ -219,10 +209,7 @@ export const layer = Layer.effect( ] as const); } return Effect.gen(function* () { - // Provide the child scope after the captured factory context. That - // context also holds the pool layer's Scope, so providing it last - // would let it shadow the child scope and leave the instance's - // stop finalizer and restart fibers running after unregister. + // Provide the child Scope last so the captured pool Scope cannot shadow it and orphan stop/restart finalizers. const instanceScope = yield* Scope.fork(layerScope, "sequential"); const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( Scope.provide(instanceScope), diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 4d95dd075cf3..6481bfd64c89 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -63,7 +63,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< } >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const httpClient = yield* HttpClient.HttpClient; @@ -85,9 +85,7 @@ export const make = Effect.gen(function* () { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } const config = configOption.value; - // A backend launched with the desktop secret accepts the current - // window's token, not the one frozen into its launch config; this - // exchange can run long after launch (e.g. after a suspend). + // Secret-authenticated backends accept the current window token; the launch token may be stale. const secret = config.bootstrap.desktopBootstrapSecret; const credential = secret === undefined diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f9a69692a250..8d178c605c4a 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -71,15 +71,7 @@ const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("169.254.") && !isTailscaleIpv4Address(address); -/** - * Bridges, tunnels and VM adapters whose private address other devices on the - * LAN usually cannot reach: macOS's Thunderbolt Bridge (`bridge0`, which can - * keep a manual address while nothing is plugged in) and Internet Sharing - * bridges, VPN tunnels, and container or VM networks. Their addresses are only - * advertised when no other interface has one. Host bridges such as `br0`, - * Proxmox's `vmbr0` and Hyper-V's `vEthernet` external switches often carry - * the LAN address, so they are not listed. - */ +// Private bridges and VM adapters are fallbacks; host bridges can carry the real LAN address. const HOST_ONLY_INTERFACE = /^(bridge\d|utun|ipsec|ppp|tun|tap|wg|docker|br-|veth|virbr|vmnet|vboxnet|VirtualBox|VMware)/; @@ -441,7 +433,7 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo previous.bindHost !== next.bindHost || previous.localHttpUrl !== next.localHttpUrl; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; @@ -449,9 +441,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); - // Each change reads the runtime state, persists settings, then writes the - // state back. Run them one at a time so a change never writes over another - // with values it read before that change persisted. + // Serialize read-persist-write changes so stale runtime state cannot overwrite another update. const changePermit = yield* Semaphore.make(1); // Key status by interface addresses so network changes bypass stale DNS and @@ -573,9 +563,7 @@ export const make = Effect.gen(function* () { customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); - // Don't spawn the Tailscale CLI when the user hasn't opted into any - // network exposure. The spawn itself triggers a macOS "Other apps" - // TCC prompt on Mac App Store Tailscale builds. + // Spawning Mac App Store Tailscale can prompt for TCC access even when exposure is disabled. if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { return coreEndpoints; } diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 044625a32b44..4b75afccdb5b 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -33,8 +33,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { for (const [interfaceName, interfaceAddresses] of Object.entries(input.networkInterfaces)) { if (!interfaceAddresses) continue; - // CGNAT space is shared with other VPNs. Native adapter identity also - // works when the CLI is missing or its cached status predates a connection. + // CGNAT is shared with other VPNs; adapter identity works without current CLI status. const isTailscaleInterface = /^tailscale(?:\d+|ipv[46])?$/iu.test(interfaceName) || interfaceAddresses.some( diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index a8abdad9bbc6..f2524f246c66 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,4 +1,2 @@ -// Enables the compile cache before the main bundle loads, so the cache also -// covers main.cjs itself. require("./compileCache.cjs"); require("./main.cjs"); diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts index da0fd66f5e58..abf8918df2af 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -3,10 +3,8 @@ import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -// Linux uses the user's cache dir because /tmp is shared between users; the macOS and Windows -// temp dirs are already per user. Skipped under AppImage: it mounts the app at a new -// /tmp/.mount_* path each launch, and Node keys entries by path, so every launch would miss -// and leave another copy behind. +// Linux /tmp is shared; macOS and Windows temp dirs are per user. +// AppImage mounts change paths each launch, so Node's path-keyed cache would miss and accumulate copies. try { if (!process.env.APPIMAGE) { const cacheRoot = diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0aae1148a482..7190c70aedc2 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -43,12 +43,7 @@ export class ElectronApp extends Context.Service< { readonly metadata: Effect.Effect; readonly name: Effect.Effect; - /** - * The OS locale, read from the operating system rather than from Chromium's - * resolved application locale — the packaged app ships only the `en-US` - * locale pak, so `app.getLocale()` and the renderer's `Intl` default are - * pinned to `en-US` however the machine is configured. - */ + /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; @@ -98,7 +93,7 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { const appVersion = yield* Effect.try({ @@ -127,9 +122,7 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // macOS derives this from NSLocale, which uses POSIX-style identifiers - // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so - // the tag is normalized here rather than in the renderer that consumes it. + // NSLocale returns en_GB-style tags; Intl requires hyphens. systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index b48953f117d3..8f0df2201616 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -104,7 +104,7 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { const ownerWindowId = Option.match(input.owner, { diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 352a5bc97a21..a58eb0c743fb 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -68,8 +68,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM continue; } - // Header items are decorative section labels for the web fallback only — - // Electron's native menu has no equivalent affordance, so we skip them. + // Native menus cannot display the web fallback's decorative headers. if (sourceItem.header === true) { continue; } @@ -97,9 +96,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } -// Renderer positions arrive in CSS pixels; popup() expects window points, so -// page zoom must be factored in or menus drift proportionally to their -// distance from the window origin. +// popup() takes window points; renderer CSS coordinates must account for page zoom. const normalizePosition = ( position: Option.Option, zoomFactor: number, @@ -112,7 +109,7 @@ const normalizePosition = ( Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; let destructiveMenuIconCache: Option.Option | undefined; diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 4fb42dfdc9f4..41c36acf2250 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -75,7 +75,7 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] ).pipe(Effect.asVoid); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d6f476205f4c..d84b6f360c89 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -78,11 +78,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu "https://challenges.cloudflare.com", ]; - // The renderer connects directly to user-configured environments in addition to - // the build-configured Clerk, relay, and OTLP endpoints. Those environment - // origins are not known when this response policy is created, so restrict - // connections by the network schemes the client supports instead of by host. - // GLTFLoader fetches embedded textures through blob URLs after parsing the model. + // User-configured environment origins are unknown here, so allow supported network schemes. + // GLTFLoader fetches embedded textures through blob URLs. const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"]; return [ @@ -110,9 +107,7 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } -/** - * Must run synchronously during process bootstrap, before Electron emits `ready`. - */ +// Must run synchronously before Electron emits ready. function registerDesktopSchemePrivilegesSync(): void { Electron.protocol.registerSchemesAsPrivileged([ { @@ -123,8 +118,7 @@ function registerDesktopSchemePrivilegesSync(): void { supportFetchAPI: true, corsEnabled: true, stream: true, - // Custom schemes skip Chromium's V8 code cache unless they opt in. - // Dev stays off: Vite serves changing code at stable URLs. + // Custom schemes must opt into V8 caching; dev URLs serve changing code. codeCache: true, }, }, @@ -246,7 +240,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( }); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const context = yield* Effect.context(); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index cd01613d92fd..0395d20e9bd3 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -65,7 +65,7 @@ export class ElectronSafeStorage extends Context.Service< } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0216d7b2c7dd..a72963c2e258 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -11,9 +11,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must -// reach the OS handler; every other non-web scheme stays blocked. +// Remote editor deep links must reach the OS handler. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -63,7 +61,7 @@ export class ElectronShell extends Context.Service< } >()("@t3tools/desktop/electron/ElectronShell") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronShell.of({ openExternal: (rawUrl) => Option.match(parseSafeExternalUrl(rawUrl), { diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 42cac98d0f53..a84eedda13b4 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -28,7 +28,7 @@ export class ElectronTheme extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTheme") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab..397044932607 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -80,7 +80,7 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronUpdater.of({ setFeedURL: (options) => Effect.suspend(() => { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 993e930678ed..60908ee90dc8 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -127,11 +127,10 @@ export class ElectronWindow extends Context.Service< } >()("@t3tools/desktop/electron/ElectronWindow") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; - // The focus worker loads a native accessibility module. Start it on the first - // capture reveal so users who never capture pay nothing at launch. + // Lazy-load native accessibility on capture reveal to avoid startup cost for other users. let windowsForegroundFocus: ReturnType | undefined; const ensureWindowsForegroundFocus = () => { windowsForegroundFocus ??= startWindowsForegroundFocusThread( diff --git a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts index 13f657e7812f..a0c2fb27a5ea 100644 --- a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts +++ b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts @@ -22,8 +22,7 @@ export const completeLegacyLocalStorage = makeIpcMethod({ result: Schema.Void, handler: Effect.fn("desktop.ipc.legacyLocalStorage.complete")(function* (_, event) { const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; - // Chromium writes localStorage to disk lazily; persist the merge before - // the marker stops later launches from retrying it. + // Flush Chromium's lazy writes before the marker prevents migration retries. if (event) Electron.webContents.fromId(event.sender.id)?.session.flushStorageData(); yield* legacy.complete; }), diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index b659054540c2..c6c2cd8fbdf9 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -262,11 +262,7 @@ const clearCache = DesktopIpc.makeIpcMethod({ }), }); -/** - * The default profile keeps the bare environment id it used before profiles - * existed, so upgrading does not strand anyone's existing logins in an orphaned - * partition. - */ +/** Keep the default profile's bare environment id so existing logins retain their partition. */ export function resolvePartitionScope( environmentId: string, profileId: string | undefined, @@ -278,9 +274,7 @@ export function resolvePartitionScope( if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { return { scope: environmentId, persistent: true }; } - // JSON's tuple framing is injective for strings, including lone UTF-16 - // surrogates (which it escapes). URI encoding throws on those supported ids, - // while replacing them with U+FFFD would collapse distinct identities. + // JSON framing preserves lone UTF-16 surrogates; URI encoding throws and replacement would collide. return { scope: JSON.stringify([environmentId, profileId]), persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, @@ -288,11 +282,7 @@ export function resolvePartitionScope( }; } -/** - * Clearing without a profile keeps the historical "everything" behaviour for - * an explicit all-profiles action; naming a profile confines it to that - * profile's partition so one profile's sign-out cannot reach the others. - */ +/** Omitting a profile clears every partition; a named profile clears only its own partition. */ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* ( manager: PreviewManager.PreviewManager["Service"], environmentId: string, @@ -300,10 +290,7 @@ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartit ) { if (profileId === undefined) return undefined; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Loading the session is what puts the partition in the map the clear walks. - // Deriving the partition string alone leaves nothing to match, so clearing a - // profile with no tab open this run — after a restart, or when deleting a - // profile — would report success and delete nothing. + // getBrowserSession registers the partition; deriving its name alone leaves unopened profiles uncleared. yield* manager.getBrowserSession(scope, persistent, namespace); return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; }); @@ -315,9 +302,7 @@ const getPreviewConfig = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Creating the session first is what installs the UA rewrite and permission - // handlers; a guest that attached to an untouched partition would run with - // Electron's default UA and Chromium's default permission behaviour. + // Session creation installs UA and permission handlers before a guest can attach. yield* manager.getBrowserSession(scope, persistent, namespace); return { partition: yield* manager.getBrowserPartition(scope, persistent, namespace), @@ -346,8 +331,6 @@ export const importBrowserCookies = DesktopIpc.makeIpcMethod({ ...importInput }) { const browserImport = yield* BrowserImport.BrowserImport; - // Derived in main from the same helper the webview config uses, so cookies - // land in exactly the partition the profile's tabs attach to. const { scope, persistent, namespace } = resolvePartitionScope( environmentId, importInput.targetProfileId, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 9066cf6f1a67..f0d661b58bc9 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -138,9 +138,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ runningDistro, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token the secret derives - // for the current window, so hand out that one rather than the token frozen into its - // launch config. + // Use the current window's derived token; the launch config's token may be stale. ...(bootstrap.desktopBootstrapSecret ? { bootstrapToken: yield* configuration.currentBootstrapToken } : bootstrap.desktopBootstrapToken @@ -194,9 +192,7 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - // Fall back to the persisted wslDistro when the id is the - // "wsl:default" sentinel; the orchestrator uses the same fallback - // for the actual backend. + // Match the orchestrator's persisted distro fallback for wsl:default. const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null; const defaultPath = useWsl ? Option.fromNullishOr( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 3c10f04512b4..dac755087146 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -22,8 +22,7 @@ const readWslState: Effect.Effect< const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); - // Only enumerate distros when WSL is actually available — listDistros on a - // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + // Listing distros on non-WSL hosts spawns wsl.exe only to time out. const distros = available ? yield* wslEnvironment.listDistros : []; const preflightError = yield* wslBackend.lastPreflightError; return { @@ -67,8 +66,7 @@ export const setWslBackendEnabled = makeIpcMethod({ yield* lifecycle.relaunch(`wslBackendEnabled=${enabled}`); return state; } - // Reconcile is idempotent and never fails; no need for a swap-style - // rollback when the WSL side has trouble coming up. + // Reconcile never fails, so no rollback is needed. yield* wslBackend.reconcile; return yield* readWslState; }), @@ -84,8 +82,7 @@ export const setWslDistro = makeIpcMethod({ const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslDistro(distro); const settings = yield* appSettings.get; - // In active wsl-only mode the pool's primary IS the WSL backend, and its - // distro is captured when that backend starts, so relaunch to replace it. + // WSL-only primary backends capture their distro at startup; relaunch to change it. if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { const state = yield* readWslState; yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); @@ -101,10 +98,7 @@ export const setWslOnly = makeIpcMethod({ payload: Schema.Boolean, result: DesktopWslStateSchema, handler: Effect.fn("desktop.ipc.wsl.setOnly")(function* (enabled) { - // wsl-only decides which backend the pool spins up as "primary", and that - // decision is captured once at layer init. A disabled WSL backend always - // leaves Windows primary active, so mode changes can be staged without a - // relaunch and applied by the subsequent enable call. + // The pool captures its primary at initialization; disabled WSL can stage mode changes until enable. const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslOnly(enabled); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index aaf0c11bcce9..ce7bc4004cf4 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -26,14 +26,12 @@ describe("linuxSecretStorage", () => { it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); - // Chromium stops at the first recognized value, so a later name cannot rescue the session. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); }); it("does not treat lowercase desktop names as ones Electron recognizes", () => { - // Chromium matches these case-sensitively, so lowercase spellings reach basic text. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index 81f891985b9d..b6d41b43a664 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -8,8 +8,7 @@ export type LinuxPasswordStoreSwitch = Exclude layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index 4a8e38961f20..0b50e147785f 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -88,8 +88,7 @@ export class MacPermissionHelper { const bundle = macAppBundlePath(Electron.app.getPath("exe")); if (!bundle) return; if (owner?.isDestroyed()) return; - // Finder's bundle-icon lookup can return the generic app icon for mounted artifacts. - // Use the same PNG that packaging uses to generate the app's macOS icon. + // Finder can return generic icons for mounted artifacts; use the packaging PNG. const appIcon = iconPaths .map((iconPath) => Electron.nativeImage.createFromPath(iconPath)) .find((image) => !image.isEmpty()); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index f529d82b5941..e5acc09abbf9 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -15,9 +15,8 @@ const SettingsWindow = Schema.NullOr( export type SettingsWindow = typeof SettingsWindow.Type; const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow)); -// Window bounds and owner PIDs are available before Screen Recording is granted. -// Use the bundle identifier rather than the localized app/window title. A single -// process avoids launching osascript repeatedly while the user moves Settings. +// Window bounds/PIDs need no Screen Recording grant. Match the bundle id to avoid localized titles. +// Keep one osascript process while Settings moves. const SETTINGS_WINDOW_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index bffa02d9ca43..4d57b30e2139 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -46,8 +46,7 @@ try { const clientPlatform = process.platform; if (clientPlatform === "darwin") { - // Native window buttons do not scale with Chromium zoom. Keep their reserved - // space in native points, including when a zoomed page is reloaded. + // Native buttons ignore Chromium zoom; reserve native points even after reload. const syncWindowControlInset = () => { document.documentElement.style.setProperty( "--desktop-window-controls-inset", diff --git a/apps/desktop/src/preview/AnnotationIds.test.ts b/apps/desktop/src/preview/AnnotationIds.test.ts index 1104c422f3ee..f1f4be518455 100644 --- a/apps/desktop/src/preview/AnnotationIds.test.ts +++ b/apps/desktop/src/preview/AnnotationIds.test.ts @@ -4,8 +4,6 @@ import { createAnnotationIdSource } from "./AnnotationIds.ts"; describe("createAnnotationIdSource", () => { it("gives annotations picked on separate page loads distinct ids", () => { - // The picker preload runs afresh on every page load, while the composer - // keeps earlier annotations keyed by id. const firstLoad = createAnnotationIdSource(); firstLoad("element"); const firstAnnotation = firstLoad("annotation"); diff --git a/apps/desktop/src/preview/AnnotationIds.ts b/apps/desktop/src/preview/AnnotationIds.ts index 2262febdd1c1..082d6540a64d 100644 --- a/apps/desktop/src/preview/AnnotationIds.ts +++ b/apps/desktop/src/preview/AnnotationIds.ts @@ -1,11 +1,4 @@ -/** - * Id source for picker annotations and their targets, one per page load. - * - * The composer keeps annotations keyed by id across preview reloads, and this - * preload restarts on every page load, so a bare counter reissues ids that a - * draft already holds. `getRandomValues` rather than `randomUUID`, because - * plain-http previews (LAN, tailnet) are not secure contexts. - */ +/** Per-load nonces avoid draft ID collisions; getRandomValues works on plain HTTP. */ export function createAnnotationIdSource(): (prefix: string) => string { const pageLoad = Array.from(crypto.getRandomValues(new Uint32Array(2)), (part) => part.toString(36), diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 4815da11cba1..c691ac15174d 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -46,8 +46,6 @@ const withImporter = Effect.fnUntraced(function* () { const root = helium.userDataDirectory(context); if (root === undefined) throw new Error("Helium has no macOS user-data directory"); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - // The cookie database is what marks a source as installed, so a fixture - // without one is reported as absent before any other check runs. yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); const importer = yield* BrowserImport.BrowserImport.pipe( @@ -96,8 +94,6 @@ describe("BrowserImport.importCookies", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { importer, root } = yield* withImporter(); - // The lock Chromium leaves while it is running, dangling target and - // all. This must stop the import before it ever asks the keychain. yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); const error = yield* importer diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e7f97eca26c0..c567b27411c0 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -41,8 +41,7 @@ export class BrowserImportFailedError extends Schema.TaggedError 0) { yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( Effect.tapError((error) => @@ -152,7 +147,7 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcess.Platform; @@ -200,19 +195,14 @@ export const make = Effect.gen(function* BrowserImportMake() { } if (platform === "darwin" && definition.engine === "chromium") { - // macOS attributes the Keychain prompt and the resulting ACL grant to the - // executable that asks, so record which one that was — in a packaged build - // it is the signed app, in dev whatever binary hosts the main process. + // macOS attributes Keychain prompts and ACL grants to the executable, which differs in dev and packaged builds. yield* Effect.logInfo("Reading browser cookie key from the keychain", { sourceId: definition.id, executablePath, }); } - // The profile directory arrives over IPC, so it is only honoured when the - // source itself reported it. Forwarding it unchecked would let `..` - // segments walk out of the browser's user-data directory and read any - // cookie database reachable on disk. + // IPC profile paths must match the source's listing to prevent traversal outside browser user-data. const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( Effect.provide(platformServices), ); @@ -226,8 +216,7 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - // The profile was listed against a database moments ago; resolve it again - // rather than assume a path, since a Chromium jar may sit under `Network/`. + // Re-resolve listed paths because Chromium jars may be under Network/. const databasePath = yield* resolveCookieDatabase( definition, pathContext, @@ -296,8 +285,7 @@ export const make = Effect.gen(function* BrowserImportMake() { ), ); - // Written one at a time rather than in parallel: Chromium's cookie store - // serialises writes anyway, and a rejected cookie should only cost itself. + // Chromium serializes writes; a rejected cookie should not prevent the others. return yield* writeCookies(session, result); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 91be0a43d1be..e5aa05f268e4 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -38,9 +38,6 @@ const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { describe("cookieScope", () => { it("keeps a host-only cookie host-only", () => { - // Chromium stores a host-only cookie without a leading dot. Passing any - // `domain` to Electron makes it a domain cookie and re-adds the dot, which - // would expose the cookie to every subdomain it was never scoped to. expect(cookieScope("example.test", "/", true)).toEqual({ url: "https://example.test/", domain: undefined, @@ -288,8 +285,7 @@ describe("readChromiumCookieDatabase", () => { const filename = `${directory}/Cookies`; const cbcV10 = Buffer.from("0123456789abcdef"); const cbcV11 = Buffer.from("fedcba9876543210"); - // The key some Linux clients actually encrypted with (crbug.com/1195256): - // OSCrypt's derivation over an empty passphrase. + // Some Linux clients encrypted with an empty passphrase (crbug.com/1195256). const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); yield* Effect.gen(function* () { @@ -352,9 +348,6 @@ describe("readChromiumCookieDatabase", () => { ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - // Chromium's OSCrypt returns unprefixed data as-is on both platforms - // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear - // text"), so neither counts it as undecryptable. const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d5f7592b2606..63fef9fb3c80 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -73,12 +73,7 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect( Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), ); -/** - * Unspecified is imported as Electron's own `unspecified` rather than pinned to - * Lax, so the target browser applies its default just as the source did; - * anything unrecognised lands there too, since guessing "none" would widen a - * cookie's scope on import. - */ +/** Preserve unspecified SameSite; guessing no_restriction would widen cookie scope. */ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { if (value === 0) return "no_restriction"; if (value === 1) return "lax"; @@ -86,21 +81,14 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { return "unspecified"; }; -/** - * The microsecond value overflows JavaScript's safe integer range and - * `node:sqlite` refuses to narrow it, so the division happens in SQL and this - * only ever sees seconds. - */ +/** Convert microseconds in SQL: node:sqlite refuses integers beyond JavaScript's safe range. */ const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; const toUnixSeconds = (webkitSeconds: number): number | undefined => { if (webkitSeconds <= 0) return undefined; return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; }; -/** - * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding - * a cookie to its domain. - */ +// Chromium 127+ prefixes plaintext with the host-key SHA-256 to bind its domain. const stripDomainBinding = ( plaintext: Buffer, domain: string, @@ -161,17 +149,14 @@ export function decryptChromiumValue( const prefix = buffer.subarray(0, 3).toString("latin1"); const payload = buffer.subarray(3); - // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and - // intentionally have no key here, so they fall through as undecryptable. + // Windows v10 uses AES-256-GCM; app-bound v20 records have no key here. if (platform === "win32") { return prefix === "v10" && keys.gcmV10 ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) : null; } - // Chromium retries a failed record with a key derived from an empty - // passphrase, because some Linux clients wrote data that way - // (crbug.com/1195256). + // Retry the empty-passphrase key used by some Linux clients (crbug.com/1195256). if (prefix === "v10") { if (!keys.cbcV10) return null; return ( diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 0d91ebe5a374..bc9edcfe52a8 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -14,7 +14,6 @@ import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; const KEY_SALT = "saltysalt"; const KEY_LENGTH = 16; -/** macOS stretches the keychain secret; Linux uses a single iteration. */ const MAC_KEY_ITERATIONS = 1003; const LINUX_KEY_ITERATIONS = 1; /** Chromium's documented fallback passphrase when no Linux keyring is present. */ @@ -42,11 +41,7 @@ export interface ChromiumKeyMaterial { readonly cbcV10?: Buffer; readonly cbcV11?: Buffer; readonly cbcV11Error?: ChromiumKeyError; - /** - * AES-128-CBC from an empty passphrase. Some Linux clients wrote records - * with it (crbug.com/1195256), so Chromium — and this import — retry with it - * after a record's own key fails. - */ + /** Linux clients can encrypt with an empty passphrase; retry it on failure (crbug.com/1195256). */ readonly cbcEmpty?: Buffer; readonly gcmV10?: Buffer; } @@ -54,21 +49,12 @@ export interface ChromiumKeyMaterial { const derive = (passphrase: string, iterations: number) => NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); -/** - * Via the CLI the prompt says "security" and "Always Allow" grants trust to a - * tool every process on the machine can invoke; in-process it names this app - * and the grant belongs to it. - * - * Deliberately untimed: macOS answers this with a modal, and a timeout racing - * the user means the prompt can be approved while nothing is left listening, - * which reads as "approving did nothing". - */ +// In-process Keychain access attributes grants to this app. Keep modal prompts untimed to preserve their listener. const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( service: string, account: string, ) { - // Only macOS cookie imports need this binding; loading it at startup can - // prevent the desktop from opening on platforms that never use it. + // Loading the macOS binding at startup can prevent other platforms from opening. const Keyring = yield* Effect.tryPromise({ try: () => import("@napi-rs/keyring"), catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), @@ -90,10 +76,7 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function return secret; }); -/** - * Its exit codes distinguish a missing key, denied access, and an unavailable - * keyring without parsing localized error messages. - */ +/** Exit codes distinguish missing keys, denied access, and unavailable keyrings without localized message parsing. */ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( application: string, ) { @@ -267,13 +250,10 @@ export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys") } if (request.platform === "linux") { - // The fallback passphrase always applies to `v10` records; a keyring - // secret, when one is reachable, additionally unlocks `v11`. + // v10 always uses the fallback passphrase; a keyring secret additionally unlocks v11. const keyringSecret = request.linuxSecretApplication ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( - // v10 remains importable when Secret Service is absent or does not - // contain a key. An explicit denial/lock/cancel remains a consent - // failure rather than being silently downgraded. + // Missing Secret Service or keys must not block v10; explicit denial, lock, or cancellation remains a consent failure. Effect.catchIf( (error) => error.reason !== "needsKeychainApproval", (error) => Effect.succeed(error), diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts index 2eb6cdbb5a17..6c708e6023df 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -9,11 +9,7 @@ export interface ImportedCookie { readonly url: string; readonly name: string; readonly value: string; - /** - * A host-only cookie leaves this undefined: Electron treats any `domain` it is given as marking a - * domain cookie and re-adds the dot, which would widen the cookie to every subdomain of the host - * it was scoped to, and rejects `__Host-` cookies, which require it to be absent. - */ + /** Leave host-only cookies undefined; Electron widens any supplied domain and rejects __Host- cookies with one. */ readonly domain: string | undefined; readonly path: string; readonly secure: boolean; @@ -47,10 +43,7 @@ export const cookieScope = ( export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); -/** - * Both engines keep the file open with WAL while the browser runs, so reading in place can observe - * a torn write. Copying also guarantees we never open the browser's own file for writing. - */ +/** Copy before reading to avoid live WAL writes and never open the source browser database for writing. */ export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( cookiePath: string, tempPrefix = "t3code-cookie-import-", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index d764b5bd6d00..be41318fa01e 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -37,8 +37,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( rawSameSite?: number; originAttributes?: string; }>, - // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in - // milliseconds, earlier ones in seconds. schemaVersion = 15, ) { const fileSystem = yield* FileSystem.FileSystem; @@ -46,7 +44,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( const file = `${directory}/cookies.sqlite`; const database = new NodeSqlite.DatabaseSync(file); database.exec(`pragma user_version = ${schemaVersion}`); - // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; database.exec( `create table moz_cookies ( @@ -87,8 +84,6 @@ describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => run( Effect.gen(function* () { - // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older - // profiles still hold seconds. Both must land as seconds for Electron. const row = { host: "example.test", name: "c", @@ -169,9 +164,6 @@ describe("readFirefoxCookies", () => { it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => run( Effect.gen(function* () { - // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite - // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in - // to cross-site use; importing it as "none" would widen its scope. const row = { host: "example.test", name: "c", @@ -198,8 +190,6 @@ describe("readFirefoxCookies", () => { it.effect("imports rows whose SameSite was never written", () => run( Effect.gen(function* () { - // Schema 9 added `sameSite` without a default, so rows from before the - // upgrade hold NULL. One such row must not fail the whole import. const row = { host: "example.test", name: "c", @@ -229,10 +219,6 @@ describe("readFirefoxCookies", () => { it.effect("applies the schema-15 rawSameSite rule to older databases", () => run( Effect.gen(function* () { - // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value - // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by - // default, None declared" into Unset; an unmigrated database has to be - // read the same way or an undeclared cookie becomes an explicit Lax. const row = { host: "example.test", name: "c", @@ -276,9 +262,6 @@ describe("readFirefoxCookies", () => { sameSite: 1, }, { - // Same host, name and path as above: Firefox keeps these apart by - // container, Electron cannot, so importing both would hand the - // profile whichever one happened to be written last. host: "mail.test", name: "session", value: "work-container", @@ -340,8 +323,6 @@ describe("readFirefoxCookies", () => { describe("parseFirefoxProfiles", () => { it.effect("reads named profiles and ignores Install sections", () => Effect.gen(function* () { - // `Install*` sections name a default profile but do not describe one, so - // counting them would invent a profile whose directory does not exist. const parsed = yield* parsePosixFirefoxProfiles( [ "[Install4F96D1932A9F858E]", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 16ad488f5cc2..a5fddb14d22d 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -17,23 +17,12 @@ export class FirefoxCookieReadError extends Schema.TaggedError= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Firefox isolates cookies per container and per private window via - // `originAttributes` (`^userContextId=2`, `^privateBrowsingId=1`); - // Electron has no equivalent, so importing them all would collapse - // several identities onto one host/name/path and hand the profile an - // arbitrary container's session. + // Electron cannot represent Firefox containers/private windows; importing them would collapse distinct sessions. const raw = hasRawSameSite ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 5420b9fc8bd5..7e92334c957d 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -158,8 +158,6 @@ describe("parseBinaryCookies", () => { }); it("rejects a page that runs past the end of the file", () => { - // `Buffer.subarray` clamps rather than throwing, so an overlong first page - // swallows the second one's bytes and advances the cursor past the end. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); @@ -215,9 +213,6 @@ describe("parseBinaryCookies", () => { describe("readSafariCookies", () => { it.effect("reports a TCC denial as a permission the user can grant", () => Effect.gen(function* () { - // What Full Disk Access actually looks like: the file is there, the read - // is refused with EPERM. Effect tags that `Unknown`, not - // `PermissionDenied`, so the reader has to look at the errno. const denied = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 15f8de8d3a36..118f9af1f776 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -8,7 +8,6 @@ import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; /** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; -/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ const COOKIE_PAGE_HEADER_SIZE = 12; const COOKIE_RECORD_HEADER_SIZE = 56; @@ -46,10 +45,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray buffer.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } @@ -74,9 +70,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray page.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } - // Every record accepted so far, so a later offset cannot point back into - // one of them: the page header, the offset table, and earlier records are - // all bytes that would otherwise parse as a fabricated cookie. + // Reject offsets into the header, table, or earlier records to avoid fabricated cookies. const accepted: Array = []; for (let index = 0; index < cookieCount; index += 1) { const cookieStart = page.readUInt32LE(8 + index * 4); @@ -120,9 +114,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; cookies.push({ - // Safari marks domain cookies with a leading dot like the other - // engines, so the shared scope rule applies: host-only cookies keep - // `domain` undefined, or Electron widens them to every subdomain. ...cookieScope(domain, path || "/", secure), name, value, @@ -130,17 +121,13 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { - // TCC denies with EPERM, which Effect tags `Unknown` rather than - // `PermissionDenied` — so the errno is what identifies it. EACCES (and the - // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or - // ACL failure that granting Full Disk Access cannot fix, so it stays a plain - // read failure rather than sending the user to a grant that won't help. + // TCC returns EPERM (Effect Unknown); EACCES is a POSIX denial that Full Disk Access cannot fix. const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; return code === "EPERM"; }; -/** - * `stat` succeeds on the jar inside Safari's container even without Full Disk - * Access — that is what lets the listing find it — so presence alone cannot - * tell granted from denied. - * */ +/** stat succeeds without Full Disk Access, so presence alone does not prove readable cookies. */ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts index 1bad2ec2b357..8e1d2c3d62d4 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts @@ -17,8 +17,7 @@ export const safariPermissionCheck = Effect.gen(function* () { if (!safari || context.platform !== "darwin") return false; const defaultJar = yield* resolveCookieDatabase(safari, context, "."); if (defaultJar !== undefined) return yield* safariAccessGranted(defaultJar); - // A Safari installation can have cookies only in a named profile. Rediscover - // those stores after a grant, since TCC may have hidden their metadata before. + // Rediscover named profiles after a grant; TCC may have hidden their metadata. const profiles = yield* listSourceProfiles(safari, context); for (const profile of profiles) { const jar = yield* resolveCookieDatabase(safari, context, profile.directory); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index cc92563c5847..d0444d42e502 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -119,9 +119,6 @@ describe("isSourceRunning", () => { const context = yield* withSourceHome(); assert.isFalse(yield* isSourceRunning(helium, context)); - // Chromium points the lock at `-`, a target that never - // exists on disk. A check that follows the link reports a running - // browser as closed, letting an import read a live, mid-write database. yield* fileSystem.symlink( "host-that-does-not-exist-1234", `${userDataDirectory(context)}/SingletonLock`, @@ -209,9 +206,6 @@ describe("isSourceInstalled", () => { const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Installers for native messaging hosts create an empty user-data - // directory for every Chromium fork they know about, so treating the - // directory as evidence lists browsers the user does not have. yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); assert.isFalse(yield* isSourceInstalled(helium, context)); @@ -307,8 +301,6 @@ describe("cookieDatabaseCandidatePaths", () => { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Chromium 96+ keeps sessions in Network/; a root Cookies left behind - // by the move is stale and must not be the one imported. yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); @@ -565,14 +557,9 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.writeFileString(`${root}/lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // `.parentlock` is deliberately left on disk after a clean exit as a - // last-used marker, so an unlocked one is not evidence of a running - // browser — treating it as one blocked every import after first use. yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // The `lock` symlink is what Firefox removes on exit; a live pid in - // its target means the profile is held. yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); assert.isTrue(yield* isSourceRunning(firefox, context)); }), @@ -582,11 +569,7 @@ describe("isSourceRunning for Firefox", () => { it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => Effect.gen(function* () { const alive = (pid: number) => Effect.succeed(pid === 4242); - // The resolver may hand Firefox any of the machine's addresses, not - // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address - // elsewhere — so every local address counts as ours. const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); - // Both the plain and the fcntl-marked (`+`) forms carry the pid. assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); @@ -603,8 +586,7 @@ describe("isSourceRunning for Firefox", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); const context = yield* sourcePathContext.pipe( - // Firefox's win32 root hangs off %APPDATA%; without it the root is - // undefined and the fixture would escape the sandbox into the repo. + // Without APPDATA, the Windows Firefox fixture would escape into the repo. Effect.provideService(HostProcess.Environment, { HOME: home, APPDATA: `${home}/AppData/Roaming`, @@ -616,8 +598,6 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.makeDirectory(profile, { recursive: true }); yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - // On Windows, Firefox creates parent.lock as a regular file that - // persists after the process exits. yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); }), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 3e1cae508a69..0bf3df235f94 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -73,9 +73,7 @@ const chromiumSource = (input: { }); export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ - // No Chromium fork is importable on Windows: since Chrome 127 their cookies - // are encrypted to the browser's own identity (App-Bound Encryption), so no - // other process can read them. + // Chrome 127+ App-Bound Encryption prevents other processes from reading Windows Chromium cookies. chromiumSource({ id: "chrome", name: "Chrome", @@ -190,8 +188,7 @@ const cookieDatabaseCandidatePaths = ( if (definition.engine === "safari") { return [context.path.join(profilePath, "Cookies.binarycookies")]; } - // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade - // leaves the legacy file behind, so prefer the current one and fall back. + // Prefer Chromium 96+'s Network/Cookies; upgrades leave the pre-96 Cookies file behind. return [ context.path.join(profilePath, "Network", "Cookies"), context.path.join(profilePath, "Cookies"), @@ -209,11 +206,7 @@ export const resolveCookieDatabase = Effect.fnUntraced(function* ( return undefined; }); -/** - * Firefox records its profiles in `profiles.ini`. `Install*` sections point at - * a default profile but do not describe one, so only `[ProfileN]` blocks - * count. - */ +/** Install* sections select defaults, not profiles; only ProfileN blocks describe profiles. */ export function parseFirefoxProfiles( ini: string, path: Path.Path, @@ -242,8 +235,7 @@ export function parseFirefoxProfiles( if (!escapesRoot) directory = path.normalize(candidate); } } else if (path.isAbsolute(candidate)) { - // Firefox supports profiles on arbitrary custom roots when - // IsRelative=0. Do not constrain them to the standard Firefox root. + // IsRelative=0 permits custom roots outside Firefox's standard directory. directory = path.normalize(candidate); } @@ -308,10 +300,7 @@ const isSafeProfileDirectory = (directory: string): boolean => const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); -/** - * Firefox containers use identities Electron cannot represent, so its count - * uses the same default-container predicate as the reader. - */ +// Count only default-container cookies because Electron cannot represent Firefox containers. const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -420,12 +409,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - // `profiles.ini` also lists profiles the installer created but the user - // never launched, which hold no cookie database and nothing to import. When - // none of the declared profiles has one, fall through to the scan rather - // than returning empty: `profiles.ini` can list stale or never-launched - // profiles while the cookies live in one it does not mention, and an empty - // answer here hides the browser entirely. + // Declared profiles may be stale or never launched; scan for unlisted stores if none has a cookie database. if (declared.length > 0) { const found = yield* Effect.forEach(declared, (profile) => Effect.forEach( @@ -460,11 +444,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( Effect.flatMap(decodeLocalState), Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), - // The keys are directory names from the browser's own metadata file, which - // anything running as the user can write. Anything but a single plain - // segment is dropped: `..` or a path separator would otherwise be handed - // to `cookieDatabasePath` and read a database outside the user-data - // directory. + // Browser metadata is user-writable; reject traversal segments before resolving cookie database paths. Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), Effect.map((entries) => entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), @@ -490,11 +470,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); }); -/** - * Include Firefox's Snap home alongside its native home. Snap profiles use - * absolute directories so cookie reads and lock checks keep pointing at the - * installation they came from, even when both installs use the same name. - */ +/** Keep Snap profile paths absolute so reads and locks distinguish native and Snap installations. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -544,9 +520,7 @@ export const chromiumProcessIsAlive = ( signalProcess(pid, 0); return true; } catch (cause) { - // Only ESRCH positively proves the process is gone. Permission errors - // and unknown failures stay conservative so an active browser is never - // mistaken for a stale lock. + // Only ESRCH proves a stale lock; permission and unknown failures may still hide an active browser. return !( typeof cause === "object" && cause !== null && @@ -570,8 +544,7 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( if (!/^\d+$/.test(pidText)) return true; const pid = Number(pidText); if (!Number.isSafeInteger(pid) || pid <= 0) return true; - // A PID is meaningful only on this host. A foreign hostname can come from a - // shared home directory, and cannot safely be declared stale from here. + // Foreign-host PIDs may come from a shared home; never declare them stale here. if (host !== currentHost) return true; return yield* isProcessAlive(pid); }); @@ -580,15 +553,9 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => error.reason._tag === "Busy"; -/** - * Whether a Windows `parent.lock` is actually held by a running process. It - * is opened with no sharing, so it persists on disk after the process exits - * and `stat` always succeeds; only trying to open it for write reveals an - * active holder, which surfaces as `Busy`. - */ +/** Windows leaves parent.lock after exit; only opening it for write reveals a live holder as Busy. */ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { - // Permission failures are distinct: they do not prove a browser owns the - // lock, so they must not hide the source as running. + // Permission failures do not prove a running browser owns the lock. const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( Effect.as(false), @@ -600,11 +567,7 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { type WindowsLockProbe = (path: string) => Effect.Effect; -/** - * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The - * live cookie database is opened without sharing instead, so probing each - * profile's current jar is the reliable running signal there. - */ +/** Windows Chromium locks cookie databases without sharing instead of creating POSIX SingletonLock symlinks. */ export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -628,9 +591,7 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( ) { const separator = target.lastIndexOf(":"); if (separator < 0) return true; - // The owner half is whatever Firefox's resolver returned for the machine's - // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN - // address — so a pid is only meaningful when that address is one of ours. + // Firefox resolves the hostname to loopback or LAN addresses; only local owners' PIDs are meaningful. const owner = target.slice(0, separator); if (!localAddresses.has(owner)) return true; // A `+` marks an fcntl-holding owner; the pid follows either way. @@ -641,18 +602,10 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( return yield* isProcessAlive(pid); }); -/** - * `/usr/bin/python3` is named absolutely first so a Dock-launched app with - * launchd's bare `PATH` still finds it without depending on the login-shell - * PATH merge; Linux distributions carry python3 on the default path. - */ +/** Dock launches have a bare PATH; try the absolute interpreter before relying on shell discovery. */ const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; -/** - * Anything else means the script never ran — most importantly Apple's - * `/usr/bin/python3` shim, which on a Mac without the Command Line Tools exits - * non-zero after printing an install prompt, without ever reaching our code. - */ +/** Apple's python3 shim can fail with an install prompt before the probe script runs. */ const FCNTL_PROBE_SCRIPT = "import fcntl,os,sys\n" + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + @@ -664,12 +617,9 @@ const FCNTL_PROBE_SCRIPT = " print('free')"; /** - * Anything else — no interpreter on any candidate path, or one that refused to - * run the script (Apple's shim without the developer tools) — is the probe - * being unavailable, not evidence about the lock. That case falls back to "not - * held" rather than "held": reporting every profile as locked forever would - * block Firefox import outright on such machines, and the SQLite snapshot - * already copes with a live database's WAL, as it does for every other engine. + * A missing or refusing interpreter (Apple's shim without developer tools) means + * the probe is unavailable, so report "not held": "held" would block Firefox import + * forever, and the SQLite snapshot already copes with a live WAL. */ const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, @@ -699,12 +649,7 @@ const posixLockIsHeld = Effect.fnUntraced(function* ( return false; }); -/** - * But `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files - * held with fcntl or a Windows handle and are *deliberately left on disk* after - * exit, as a last-used marker; treating them as proof of a running browser - * blocks every import after Firefox has been used once. - */ +/** Firefox leaves regular lock files after exit; test the lock, not file existence. */ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( directory: string, context: BrowserImportPathContext, @@ -739,8 +684,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Safari keeps no lock and writes its jar atomically, so a running instance - // is not a hazard there. + // Safari writes its jar atomically and needs no lock, even while running. if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { @@ -766,18 +710,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") return found.some(Boolean); }); -/** - * Keyed off the cookie database rather than the user-data directory, because - * that directory is not evidence the browser exists: installers for native - * messaging hosts create an empty one for every Chromium fork they know about, - * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as - * present. - * - * Existence is checked without opening the file, which matters for Safari: TCC - * permits `stat` on the jar inside its container but refuses a read, so this - * still sees it and the user gets the Full Disk Access prompt rather than - * having Safari disappear. - */ +/** Require a cookie database; installers create empty directories. Safari stat needs no Full Disk Access. */ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 45d2aeafdbfa..d9b797dd0451 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -85,8 +85,6 @@ describe("BrowserSession", () => { Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; - // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these - // two supported ids would hash to one partition and share every cookie. const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); const replacementChar = yield* browserSessions.getPartition("p\ufffd"); assert.notStrictEqual(loneSurrogate, replacementChar); @@ -116,13 +114,8 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A rewritten session UA — any variant, even ones that keep the Electron - // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so the - // guest must end up with Electron's native User-Agent. it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => Effect.gen(function* () { - // Electron's real UA shape: app token, then Chrome, then Electron, then - // Safari — the token order and casing matter to any strip regex. const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; fromPartition.mockReset(); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 3847ec332331..0f900d521b73 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -12,37 +12,21 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -/** - * Incognito partitions deliberately omit the `persist:` prefix, which is what - * makes Chromium keep them in memory and discard them with the process. They - * still carry the product prefix so `isPartition` can admit them — the - * `will-attach-webview` gate rejects anything it does not recognise. - */ +/** Ephemeral partitions omit persist: but retain a product prefix so the webview gate recognizes them. */ const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; const PROFILE_PARTITION_MARKER = "profile-"; export type BrowserSessionPartitionNamespace = "profile"; -// `clipboard-sanitized-write` is the Electron permission behind -// `navigator.clipboard.writeText()` — note it is NOT `clipboard-write`, which -// is not a valid Electron permission name. Async clipboard writes are gated by -// the permission *check* handler (not only the request handler), so both -// handlers must allow it; otherwise built-in "Copy" buttons — e.g. the Next.js -// / Vercel error overlay — fail with `Failed to execute 'writeText' on -// 'Clipboard': Write permission denied`. +// Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-read", "clipboard-sanitized-write", "geolocation", - // `DesktopWindow` disables HTML fullscreen window resizing for the app window - // and every preview guest, so a page that goes fullscreen fills its own - // webview instead of taking over the whole T3 window. + // DesktopWindow confines HTML fullscreen to the guest's webview. "fullscreen", - // Deliberately NOT local-fonts: preview sessions run untrusted web content, - // and silently granting it would hand every page the user's installed-font - // fingerprint (and font file bytes via FontData.blob()). - // Deliberately NOT notifications: a page could post OS notifications branded - // as T3 Code. T3's own come from the app window on the default session. + // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes. + // Deny notifications: pages could impersonate T3 Code; app notifications use the default session. ]); const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ @@ -166,12 +150,7 @@ const selectSessions = ( ([partition]) => partitions === undefined || partitions.includes(partition), ); -/** - * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` - * and `"p\ufffd"` would hash to the same partition and share cookies. Those - * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` - * first — and a literal backslash is doubled so the escape cannot be forged. - */ +/** Escape lone surrogates before UTF-8 hashing, which otherwise merges them with U+FFFD; escape backslashes too. */ const encodeScopeForDigest = (scope: string): Uint8Array => new TextEncoder().encode( scope @@ -182,10 +161,7 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ), ); -/** - * A `` guest has no window of its own, so look it up through the page - * that embeds it. - */ +// Webview guests have no window; use their embedding page. const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { if (guest === null || guest.isDestroyed()) return undefined; const host = guest.hostWebContents ?? guest; @@ -193,18 +169,14 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined return BrowserWindow.fromWebContents(host) ?? undefined; }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. let externalPromptOpen = false; - /** - * Electron grants an external-protocol navigation through the `openExternal` - * permission and then launches the OS handler itself. Ask first: previews run - * untrusted pages, and a silent grant would let any of them launch apps. - */ + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ const confirmOpenExternal = ( requester: WebContents | null, rawUrl: string | undefined, @@ -276,11 +248,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); - // The guest keeps Electron's native User-Agent. Rewriting it in any - // form — even variants that keep the Electron token — makes Cloudflare - // Turnstile fail its integrity check with error 600010 and recreate - // the challenge every few seconds, so logins behind it never complete - // (#5002). + // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { if (permission === "openExternal") { diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 2d17a2874454..ec314d1f9d47 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -104,7 +104,6 @@ describe("CDP relay", () => { expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); await settle(); - // Electron passes an empty session id for the page's own events. relay.event("Page.loadEventFired", { timestamp: 1 }, ""); relay.event("Runtime.consoleAPICalled", {}, "iframe-session"); expect(written.slice(-2)).toEqual([ diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 581b692882b3..82264cc4b870 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -1,7 +1,4 @@ -/** - * Electron allows one debugger client per webContents, so the relay shares the - * attach the preview manager already holds. - */ +// Electron allows one debugger per webContents; share the manager's attachment. export interface CdpRelayTarget { readonly send: ( @@ -14,10 +11,7 @@ export interface CdpRelayTarget { readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** - * The desktop app and the server it launched share a disk, so the server's - * Playwright reads the file where it asked for it. - */ + /** The desktop and its server share a disk; download paths need no translation. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -56,11 +50,7 @@ export function createCdpRelayConnection( ): CdpRelayConnection { const send = (message: Record) => write(JSON.stringify(message)); let attached = false; - /** - * Sessions this relay invented. Extra page sessions (Playwright's - * `newCDPSession`) all reach the one real debugger session, so each gets the - * page's events; other ids are the page's own child sessions, such as iframes. - */ + /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); let sessionSequence = 0; const newSession = (kind: "browser" | "page") => { @@ -143,10 +133,7 @@ export function createCdpRelayConnection( const pageCommand = (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; - // The page itself is already running, but its child frames are not: - // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses - // every new cross-site iframe and worker until this reaches it. Swallowing - // it left sign-in checks and captchas, which run in such frames, frozen. + // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command. if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { return Promise.resolve({}); } @@ -167,8 +154,7 @@ export function createCdpRelayConnection( ? browserCommand(command) : pageCommand(command); const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; - // Replies leave as commands finish, as Chromium's do, so a slow command - // such as a screenshot never holds up the ones behind it. + // Reply as commands finish so slow commands cannot block later replies. void result.then( (value) => { const announce = diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7028d16b4be2..849ae658a821 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. -/** - * The preview manager owns the tab's single debugger session and hands it here; - * the relay shares it. - */ +/** The manager owns the single debugger attachment; the relay shares it. */ import { DesktopBrowserCommand, DesktopBrowserEvent, @@ -156,10 +153,7 @@ export const make = Effect.gen(function* () { return relay; }; - /** - * Without a path Electron would open its Save dialog over the app for a file - * the agent asked for. CDP names the download just before this runs. - */ + // CDP supplies the path before this runs; otherwise Electron opens a Save dialog. const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { const tab = [...tabs.values()].find( (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory, diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts index a2b36d182a8f..4716645c9def 100644 --- a/apps/desktop/src/preview/FaviconCapture.ts +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -669,8 +669,7 @@ async function rasterizeFavicon( ); if (signal.aborted) onAbort(); }); - // The logical timeout does not cancel Electron's renderer work. Keep the - // gate closed until that physical execution actually settles. + // Logical timeouts leave renderer work running; keep the gate closed until it settles. const launchAllowed = execution.then( () => undefined, () => undefined, diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index 6cb297ff81e9..b531182c9295 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; -/** A guest whose page reports `focusedDocument` for the focus probe. */ const makeGuest = ({ focused, focusedDocument = true, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 35006dfcec08..42201b48ed61 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -1,26 +1,12 @@ -/** - * Keyboard and text input for a preview tab's page that never lands outside it. - * - * Chromium sends a page's CDP key and text input to whichever frame holds focus - * in the window. A `` guest shares that focus with the app around it, - * so while a person works in the app, `Input.insertText` on the guest's own - * debugger types into the app's focused field. Such input goes through CDP only - * while the guest holds focus. Otherwise it goes straight to the guest's own - * widget, which nothing redirects. - */ +// CDP input follows shared window focus; send directly to unfocused guests to avoid typing into the app. -/** CDP input Chromium delivers to the window's focused frame rather than the page's. */ const FOCUS_ROUTED_INPUT = new Set([ "Input.insertText", "Input.dispatchKeyEvent", "Input.imeSetComposition", ]); -/** - * Page script: the document holding the page's focus, or null when a cross-site - * frame holds it. Electron's direct input reaches only the main frame's process, - * and `insertText` crashes the page when focus is in another one. - */ +/** Direct input reaches only the main-frame process; insertText crashes when a cross-site frame has focus. */ const FOCUSED_DOCUMENT = `(() => { let doc = document; for (;;) { @@ -31,11 +17,7 @@ const FOCUSED_DOCUMENT = `(() => { } })()`; -/** - * macOS editing commands a key press asks for (Cmd+A is `selectAll`), which - * Chromium runs only for CDP keys. Clipboard commands stay out: they would read - * or replace the person's clipboard. - */ +/** Electron runs editing commands only for CDP keys; exclude clipboard commands to preserve the user's clipboard. */ const EDITING_COMMANDS = new Set(["selectAll", "undo", "redo"]); const CROSS_SITE_FOCUS_MESSAGE = @@ -74,18 +56,13 @@ const MODIFIER_BITS = [ [8, "shift"], ] as const; -/** Electron's accelerator name for a DOM key value, or null when it has none. */ const keyCodeFor = (key: string) => { if ([...key].length === 1) return key; if (ARROW_KEYS[key]) return ARROW_KEYS[key]; return NAMED_KEYS.has(key) || /^F(?:[1-9]|1\d|2[0-4])$/u.test(key) ? key : null; }; -/** - * The Electron key events for one CDP `Input.dispatchKeyEvent`. Electron - * derives key, code, and a char's text from `keyCode`, so typed text travels - * as `char` events of its own. - */ +/** Electron derives text from keyCode, so typed text needs separate char events. */ export const toKeyboardInputEvents = ( params: Record, ): Array => { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6ded53bfaf1b..85fa6115477a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -80,8 +80,6 @@ describe("previewWindowOpenAction", () => { }); it("opens a real window for scripted popups so the opener survives", () => { - // OAuth SDKs read a null `window.open()` as a blocked popup, and they need - // the opener alive to receive the credential back. expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup"); expect( PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })), @@ -89,7 +87,6 @@ describe("previewWindowOpenAction", () => { }); it("keeps a form POST with a body on the in-place path", () => { - // A new tab can only reopen the URL as a GET, which would drop the body. const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; expect( PreviewManager.previewWindowOpenAction({ @@ -100,9 +97,6 @@ describe("previewWindowOpenAction", () => { }); it("does not hand a window to schemes that cannot be hardened", () => { - // A popup skips the `will-attach-webview` hardening, so it only gets a window - // when its preferences can be overridden. Chromium copies the guest's - // preferences for `about:blank` and forbids overriding them. for (const url of [ "javascript:alert(1)", "file:///etc/passwd", @@ -114,8 +108,6 @@ describe("previewWindowOpenAction", () => { }); it("denies a blank window instead of blanking the opener", () => { - // MSAL opens `about:blank` first and falls back to a redirect on `null`; - // loading the blank page into the tab would destroy that fallback. for (const url of ["about:blank", ""]) { for (const disposition of ["new-window", "foreground-tab"] as const) { expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); @@ -249,7 +241,6 @@ const withManager = ( interface TestCapturedPreviewImage { readonly toJPEG: () => Buffer; - /** Only fixtures that deliver picture-in-picture frames need pixels to fingerprint. */ readonly toBitmap?: () => Buffer; readonly getSize: () => { readonly width: number; readonly height: number }; } @@ -608,7 +599,6 @@ describe("PreviewManager", () => { const older = makeFaviconWebContents({ id: 42 }); const newer = makeFaviconWebContents({ id: 43 }); for (const tab of [older, newer]) Object.assign(tab.webContents, { hostWebContents: host }); - // Electron names one arbitrary guest here when several share a focused window. getFocusedWebContents.mockReturnValue(newer.webContents as never); fromId.mockReturnValue(older.webContents); yield* manager.createTab("tab_older"); @@ -634,7 +624,6 @@ describe("PreviewManager", () => { expect(preventDefault).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "view.reopenClosed"); - // The person types in the older tab, so keys reaching the newer one are an agent's. Object.assign(newer.webContents, { focusedFrame: null }); const agentInput = newer.listeners.get("before-input-event")!; const agentPreventDefault = vi.fn(); @@ -770,9 +759,6 @@ describe("PreviewManager", () => { effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { - // Real Electron throws on any `wc.debugger` access once the - // WebContents is destroyed, so cleanup must go through the debugger - // reference captured at attach time (electron/electron#53376). let destroyed = false; let attached = false; const debuggerOff = vi.fn(); @@ -1061,7 +1047,6 @@ describe("PreviewManager", () => { id, isDestroyed: () => destroyed, isDevToolsOpened: () => { - // Electron throws from native methods once a WebContents is destroyed. if (destroyed) throw new TypeError("Object has been destroyed"); return false; }, @@ -1173,11 +1158,7 @@ describe("PreviewManager", () => { audible = next; listeners.get("audio-state-changed")?.({ audible: next } as never); }, - /** - * Starts playing between the attach-time read and the post-attach - * reconcile, without a delivered event — the window in which - * audio-state-changed fires against a guest the tab does not own yet. - */ + /** Simulate playback starting during attach before the tab owns the guest and can receive its events. */ startPlayingAfterFirstRead: () => { audibleAfterFirstRead = true; }, @@ -1237,9 +1218,6 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_audio_window", 42); yield* Effect.yieldNow; - // audio-state-changed for this transition was dropped: it fired before - // the tab owned the guest. Without a post-attach reconcile the icon - // stays wrong until the next real transition, which may never come. expect(states.at(-1)?.audible).toBe(true); }), ), @@ -1369,7 +1347,6 @@ describe("PreviewManager", () => { fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); - // it.effect keeps TestClock fixed until explicitly advanced. const first = yield* manager.captureScreenshot("tab_1"); const second = yield* manager.captureScreenshot("tab_1"); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e9578c711ae4..b9e0192ca8a7 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,10 +1,3 @@ -/** - * Desktop side of the in-app browser preview. - * - * Hosts per-tab Chromium WebContents references (the actual - * elements live in the renderer; we only attach listeners and forward state - * here). Single layer-scoped browser session partition. - */ import * as NodeZlib from "node:zlib"; import { DesktopPreviewRecordingInputSchema, @@ -123,10 +116,7 @@ const ZOOM_EPSILON = 0.001; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; -/** - * Cold guests can reject capturePage with UnknownVizError or never settle it. - * Bound each attempt so snapshots release control even when Chromium stalls. - */ +// Cold guests can reject capturePage with UnknownVizError or stall; bound each attempt. const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; @@ -325,22 +315,12 @@ const cropAnnotationScreenshot = ( return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; -/** - * Electron converts `toBitmap()` output to sRGB by default. Captures on P3 Macs are - * P3, so that conversion costs more per tick than the JPEG encode the fingerprint - * replaces. An invalid target color space has no Skia color space, so `readPixels` - * copies the pixels without a color transform. The fingerprint only compares frames - * with each other, so color accuracy is irrelevant. - */ +// An invalid color space skips costly P3-to-sRGB conversion; fingerprints need no color accuracy. const FINGERPRINT_BITMAP_OPTIONS: Electron.ToBitmapOptions = { colorSpace: { primaries: "invalid", transfer: "invalid", matrix: "invalid", range: "invalid" }, }; -/** - * Identity of a captured frame: cheap enough to compute every tick, unlike a JPEG - * encode. Null when the pixels could not be read (Electron reports a failed - * `readPixels` as an empty buffer), so the caller treats the frame as changed. - */ +/** Empty readPixels results have no fingerprint and must be treated as changed frames. */ const fingerprintCapturedFrame = (image: Electron.NativeImage, size: Electron.Size) => { let bitmap: Buffer; try { @@ -421,10 +401,7 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; - // Electron's Debugger is GC-managed but registered with Chromium as a raw - // DevToolsAgentHostClient pointer; collecting it while attached crashes the - // browser process (electron/electron#53376). Detach must also go through - // this reference: `wc.debugger` throws once the WebContents is destroyed. + // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; @@ -443,13 +420,7 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; -/** - * `about:blank` stays out: Chromium skips browser-side navigation for it, so the child copies - * the guest's `contextIsolation: false` preferences and Electron gives no way to override them. - * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits - * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` and not in a - * window spawned by a third-party page in the preview. - */ +// about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); const isPopupUrl = (rawUrl: string): boolean => { @@ -460,12 +431,7 @@ const isPopupUrl = (rawUrl: string): boolean => { } }; -/** - * A popup is not a webview attach, so the `will-attach-webview` hardening in `DesktopWindow` - * never sees it, and an unoverridden child would inherit the guest's relaxed posture: the - * picker preload needs `contextIsolation: false` to share `globalThis` with the previewed page, - * and no OAuth provider should get that. - */ +/** Popups bypass will-attach-webview hardening and would inherit the picker's contextIsolation:false. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { contextIsolation: true, @@ -474,18 +440,13 @@ const POPUP_WINDOW_OPTIONS = { }, } satisfies Electron.BrowserWindowConstructorOptions; -/** - * `"popup"` opens a real window, which scripted popups need: denying them makes `window.open()` - * return `null` (OAuth SDKs report that as a blocked popup), and navigating the preview tab - * instead destroys the opener the popup has to `postMessage` its result back to. Schemes a - * popup cannot be hardened for keep loading in place, and so does a form POST with a body, - * which a new tab could only reopen as a GET. - */ +/** OAuth needs a surviving popup opener; unhardenable schemes and form POSTs must stay in place. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" | "deny" => { + // MSAL redirects on a denied blank popup; navigating the tab would destroy that fallback. if (details.url === "" || details.url === "about:blank") return "deny"; if (!isPopupUrl(details.url)) return "navigate"; if (details.disposition === "new-window") return "popup"; @@ -590,9 +551,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); - // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy - // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream - // (electron#44618) and now always rejects with NotAllowedError. + // Legacy tab capture is unsupported (electron/electron#44618); recording uses setDisplayMediaRequestHandler. let pendingRecording: PendingRecording | null = null; const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; @@ -897,10 +856,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible - // can commit between the modify above and here, and republishing this - // snapshot would roll the UI back to a value that writer will not send - // again because it suppresses unchanged audibility. + // syncTabAudible can commit after this update; emitIfCurrent prevents publishing a stale snapshot. if (Option.isSome(next)) { if (patch.controller !== undefined && next.value.webContentsId != null) { const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); @@ -917,11 +873,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - /** - * Anything that applies zoom after an await goes through here: a snapshot taken before the - * await can be older than a zoom action that landed in between, and re-applying it would roll - * that action back. - */ + /** Read zoom after awaits so concurrent zoom actions cannot be rolled back by an older snapshot. */ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || tab.webContentsId == null) return; @@ -932,11 +884,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ).pipe(Effect.ignore); }); - /** - * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute - * onto whichever guest it currently owns, reading both at call time so an - * older snapshot can never roll back a mute action that landed after it. - */ + /** Read committed mute and current guest at call time so stale snapshots cannot roll back newer actions. */ const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( tabId: string, ) { @@ -1098,8 +1046,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const wcDebugger = wc.debugger; const consoleReleases = yield* Queue.sliding(1); - // Console message eviction does not release the debugger's strong object handles. We - // keep text only, so release the whole group, including its object-id bookkeeping. + // Console eviction leaves strong debugger handles; release the group because we keep only text. yield* Effect.forkIn( Effect.forever( Queue.take(consoleReleases).pipe( @@ -1198,9 +1145,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("detach", onDetach); wcDebugger.attach("1.3"); }); - // Without this, dark-scheme pages with no background of their own (text/plain, e.g. - // .md files) render white text on white. Sent first because a document that paints - // before it arrives keeps the transparent base until its next load. + // Set an opaque base before first paint or background-less dark pages retain white-on-white text. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1250,9 +1195,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const url = wc.getURL(); const title = wc.getTitle(); if (url === "" || url === "about:blank") return { kind: "Idle" }; - // Main frame only. `isLoading()` covers the whole frame tree, so a - // cross-origin iframe that loads after the page can leave it true with no - // later event to clear it, and the tab's loading bar never finishes. + // isLoading() includes late iframes whose completion may never clear the tab's loading bar. if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; }; @@ -1291,9 +1234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { return [Option.none(), tabs] as const; } - // Electron emits did-stop-loading after did-fail-load. At that point the - // failed guest is no longer "loading", but it has not successfully - // navigated anywhere. + // did-stop-loading follows did-fail-load without proving successful navigation. const navStatus = preserveLoadFailure && current.navStatus.kind === "LoadFailed" && @@ -1311,9 +1252,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - // zoomFactor is deliberately not read back from the guest: Chromium - // reports the level it inherited from the app window, so mirroring it - // would turn an app zoom into the preview's own zoom. + // Guest zoom inherits app zoom; mirroring it would overwrite the tab's own setting. updatedAt, }; return [ @@ -1517,8 +1456,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); }; - // Not getFocusedWebContents(): every guest of a focused window reports focus. - // An agent's keys reach a guest without a focused frame through GuestInput. + // Every guest of a focused window reports focus; GuestInput handles guests without a focused frame. const holdsKeyboardFocus = (contents: Electron.WebContents) => contents.isFocused() && contents.focusedFrame !== null; const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => { @@ -1527,9 +1465,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function !isPreviewEditingShortcut(input, hostPlatform) || !holdsKeyboardFocus(contents), ); }; - // A popup opens with Electron's default handler, so the page inside it could - // otherwise spawn native windows without limit. Nothing in an OAuth flow - // opens a second popup, so the chain stops at the first one. + // Popups inherit Electron's unrestricted window handler; block nested popups to bound the chain. const windowCreated = (window: Electron.BrowserWindow): void => { window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); @@ -1872,15 +1808,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Always assert the tab's own zoom rather than reading the guest's: a guest - // attaching while the app UI is zoomed starts at the embedder's inherited - // zoom level, which is not the preview's zoom. Done before the guest is - // published so it never paints a frame at the inherited zoom. + // Restore the tab's zoom before publishing the guest so it never paints at inherited app zoom. yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => wc.setZoomFactor(currentTab.zoomFactor), ); - // A replacement guest attaches unmuted, so reassert the tab's mute before it is published - // rather than letting it emit audio the user already silenced. + // Replacement guests start unmuted; restore mute before publishing them. yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => wc.setAudioMuted(currentTab.audioMuted), ); @@ -1934,16 +1866,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; - // A zoom or mute action that landed while this attach was in flight - // addressed the guest this one replaced, so settle the new guest on the - // committed values. + // Concurrent actions targeted the previous guest; reconcile this one with committed zoom and mute. yield* assertTabZoom(tabId); yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); yield* emitIfCurrent(tabId, registered); - // Transitions that fired before the tab owned this guest were dropped by - // syncTabAudible's ownership check, so re-read and reconcile through the - // same path the event uses. + // Ownership checks dropped pre-attach audibility events; reconcile through the event path. yield* syncTabAudible(tabId, wc, yield* readAudible); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); @@ -1975,9 +1903,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Called when a guest attaches to the window, before its first document paints. A tab opened - // straight to a URL often paints before the renderer gets to registerWebview, which would - // leave that page on the transparent base. + // URL tabs can paint before registerWebview; set the background at attach time. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { @@ -2016,10 +1942,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", - // Mute is user intent, and the old document keeps playing until loadURL - // actually replaces it, so clearing audibility here would drop the - // speaker with no transition left to restore it. Chromium reports the - // change when it happens. + // Keep audibility until Chromium replaces the document; clearing it here can lose the final transition. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", @@ -2177,8 +2100,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); - // Only drop the slot while it is still ours. A newer session may - // already have swapped itself in before cancelling this one. + // A newer session may already own the slot when this one is cancelled. yield* Ref.update(pickSessionsRef, (sessions) => sessions.get(tabId) === session ? replaceMap(sessions, (copy) => { @@ -2270,9 +2192,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function : { annotation: { ...payload, screenshot }, submission }, }), Effect.flatMap((result) => { - // A capture that outlives its session must not touch the - // overlay: the preload tears down on the captured signal, and - // by now it may be running a newer pick. + // Late captures must not signal an overlay that may now belong to a newer pick. if (!claimSettle()) return Effect.void; return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, @@ -2291,10 +2211,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (event.isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { - // Two picks on one tab can overlap. Swap this session in and cancel - // the previous holder in one step, so no third pick can slip into an - // empty slot in between and the session we push out still resumes - // its renderer. + // Atomically swap/cancel overlapping picks so another pick cannot claim a transient empty slot. const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ sessions.get(tabId) ?? null, replaceMap(sessions, (copy) => { @@ -2302,9 +2219,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ]); if (replaced) yield* replaced.cancel; - // A newer pick may have cancelled this session while the previous - // one was torn down. Cleanup already ran, so attaching listeners now - // would leak them and start an overlay nobody is waiting on. + // A newer pick may cancel during teardown; do not leak listeners or start an abandoned overlay. if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); @@ -2352,9 +2267,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); - // Emulated media lives on the CDP debugger session, not the WebContents, so - // it is lost whenever the session detaches (webview swap, DevTools - // open/close) and must be re-applied after every (re)attach. + // Media emulation belongs to the debugger session and must be restored after every reattach. const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( tabId: string, wc: Electron.WebContents, @@ -2440,8 +2353,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (tab.colorScheme !== colorScheme) { yield* update(tabId, { colorScheme }); } - // Re-read after the update: registerWebview may have swapped the guest - // in the meantime and the override must land on the current one. + // registerWebview may swap guests during the update; apply to the current one. const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; if (webContentsId == null) return; const wc = webContents.fromId(webContentsId); @@ -2457,10 +2369,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Commit and apply under the tab's lifecycle lock, then assert the - // committed value rather than this call's argument. Two overlapping toggles - // would otherwise be free to commit in one order and reach Chromium in the - // other, leaving the icon disagreeing with the guest. + // Serialize commit/apply, then read committed mute so concurrent toggles cannot reach Chromium out of order. yield* withTabLifecycleLock( tabId, Effect.gen(function* () { @@ -2469,8 +2378,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (committed) { yield* update(tabId, { audioMuted }); } - // Roll the commit back if Chromium refused: reporting success here - // would leave the tab drawn as muted while it keeps playing. + // Roll back rejected mute commits so the UI reflects actual playback. yield* assertTabAudioMuted(tabId).pipe( Effect.tapError(() => committed ? update(tabId, { audioMuted: previous }) : Effect.void, @@ -2582,9 +2490,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (frameSession?.scope !== captureSession.scope) return; - // Fingerprint the raw pixels before encoding so an unchanged page skips the - // synchronous JPEG encode, the base64 copy and the IPC send. An unreadable - // frame has no fingerprint and is always delivered. + // Fingerprint before encoding so unchanged frames skip JPEG, base64, and IPC; unreadable frames must still be sent. const fingerprint = fingerprintCapturedFrame(image, size); const pictureInPicture = frameSession.consumers.has("picture-in-picture") && @@ -2939,8 +2845,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (hostPlatform === "darwin") { pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, - // Electron otherwise temporarily transforms the entire app into - // a UIElement process, which removes the owning app from the Dock. + // Electron's temporary UIElement transformation would remove the app from the Dock. skipTransformProcessType: true, }); } @@ -3075,10 +2980,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (pendingRecording?.tabId === tabId) pendingRecording = null; }; - /** - * A display-media request carries no tab identity, so the slot is exclusive: a second tab - * arming before the first request lands would redirect the first renderer's stream. - */ + /** Display-media requests lack tab identity; only one tab may arm a recording to prevent stream redirection. */ const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( tabId: string, wc: Electron.WebContents, @@ -3105,8 +3007,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function armedAtMillis: now, }; pendingRecording = armed; - // The handler callback is sync and cannot read a clock, so expiry is driven from here. - // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. + // The synchronous callback cannot read a clock. Compare identity so expiry cannot clobber a re-arm. yield* Effect.forkIn( Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( Effect.andThen( @@ -3119,8 +3020,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that - // `startRecording` armed, and denies anything else so pages cannot capture on their own. + // Only serve the tab armed by startRecording; pages must not capture independently. const installDisplayMediaRequestHandler = (session: Session) => { if (displayMediaHandlerSessions.has(session)) return; displayMediaHandlerSessions.add(session); @@ -3679,7 +3579,7 @@ export class PreviewManager extends Context.Service< } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/PasskeyBackendMac.ts b/apps/desktop/src/preview/PasskeyBackendMac.ts index ccdffa2a70c0..91cefaddc42f 100644 --- a/apps/desktop/src/preview/PasskeyBackendMac.ts +++ b/apps/desktop/src/preview/PasskeyBackendMac.ts @@ -6,8 +6,7 @@ import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; const ES256 = -7; -// WebAuthn rejects RP IDs that are public suffixes, private registries -// included, so one github.io site cannot mint passkeys for every other one. +// WebAuthn rejects public suffix RP IDs, including private registries such as github.io. const isPublicSuffix = (domain: string) => domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; @@ -32,13 +31,10 @@ const toBase64Url = (value: BufferSource) => : new Uint8Array(value), ).toString("base64url"); -/** - * Needs Apple's managed browser entitlement in the signed build. - */ +/** Requires Apple's managed browser entitlement in the signed build. */ export const macPasskeyBackend: PasskeyBackend = { create: async (publicKey, context): Promise => { - // The native layer only converts P-256 keys and never settles for any - // other algorithm, so ES256 is the only one it may negotiate. + // The native layer only converts P-256 keys, so negotiate only ES256. const params: unknown = publicKey.pubKeyCredParams; if (params !== undefined && !Array.isArray(params)) return { success: false, error: "TypeError" }; @@ -56,8 +52,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer reports parsed authenticator data as JSON and claims - // every credential is a synced platform passkey; neither is reliable. + // Native parsed authenticator JSON and synced-platform-passkey claims are unreliable. const authData = authenticatorDataFromAttestation( Buffer.from(result.data.attestationObject, "base64url"), ); @@ -79,8 +74,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer applies the site's allow list to platform passkeys only, - // so a security key can answer with a credential the site did not ask for. + // Native allow lists cover only platform passkeys; security-key responses need checking here. const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : []) .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id)) .map((credential) => toBase64Url(credential.id)); diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 3b72ab589075..998a9298baf3 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -27,10 +27,6 @@ const illegalInvocation = (): never => { throw new TypeError("Illegal invocation"); }; -/** - * Installs the bridge over fresh stand-ins for the page's DOM classes. Like - * Chromium's, their getters only work on objects the browser created itself. - */ const install = (respond: (channel: string) => unknown) => { const nativeCreate = vi.fn(async () => "native-create"); const nativeGet = vi.fn(async () => "native-get"); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts index fbb9fefa4aa5..bc77b38c1cf0 100644 --- a/apps/desktop/src/preview/PasskeyBridge.ts +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -44,10 +44,7 @@ const ceremonyError = (name: string) => DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", ); -/** - * Chromium ignores members it does not know, so functions and other uncloneable - * values drop out here instead of failing the whole ceremony. - */ +// Chromium drops unknown members; discard uncloneable values instead of failing the ceremony. const toCloneable = (value: unknown): unknown => { if (value instanceof ArrayBuffer) return value.slice(0); if (ArrayBuffer.isView(value)) { @@ -64,9 +61,7 @@ const toCloneable = (value: unknown): unknown => { return value; }; -// The objects below borrow the native prototypes so `instanceof` checks pass. -// Their own properties shadow the native getters, which only work on objects -// Chromium created itself. +// Shadow native getters: they reject objects Chromium did not create, even with the correct prototype. const withValues = (target: T, values: Record) => { for (const [key, value] of Object.entries(values)) { Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); @@ -202,10 +197,7 @@ const credentialFromGetResult = (data: Extract signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); -/** - * Conditional (autofill and automatic upgrade) requests stay native: the system - * sheet is modal and must not open on its own. - */ +/** Keep conditional requests native so autofill and upgrades cannot open a modal sheet unprompted. */ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { return; diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index f848aff47b8f..219c67fac506 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -120,7 +120,6 @@ describe("PreviewPasskeys", () => { assert.isFalse(options.isPublicSuffix("example.com")); assert.isFalse(options.isPublicSuffix("localhost")); - // WebAuthn defaults the RP ID to the caller's own host. yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); @@ -218,7 +217,6 @@ describe("PreviewPasskeys", () => { allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }], }; - // macOS applies the allow list to passkeys but not to security keys. webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); const other = yield* call(PASSKEY_GET_CHANNEL, publicKey); assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" }); @@ -244,7 +242,6 @@ describe("PreviewPasskeys", () => { const lan = makeGuest("http://192.168.1.5:3000"); passkeys.attachGuest(lan.guest); assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); - // macOS refuses `*.localhost`, so the page hears no rather than a native error. const subdomain = makeGuest("http://app.localhost:3000"); passkeys.attachGuest(subdomain.guest); assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts index 5456aaab6a8a..34db1404aacb 100644 --- a/apps/desktop/src/preview/Passkeys.ts +++ b/apps/desktop/src/preview/Passkeys.ts @@ -84,8 +84,7 @@ const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000; -// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony -// unsettled, and the page must still get an answer. +// Native ceremonies can remain unsettled; match its deadline clamp with a margin. const ceremonyDeadline = (timeout: unknown) => (typeof timeout === "number" && timeout > 0 ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS) @@ -99,9 +98,7 @@ const withDeadline = (ceremony: Promise, milliseconds: nu return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer)); }; -// Secure contexts only: https, or http on this machine. Chromium also trusts -// `*.localhost`, but the macOS layer refuses it, so it is turned away here -// instead of failing deeper with a less useful error. +// macOS refuses *.localhost even though Chromium trusts it. const isTrustworthyOrigin = (origin: string) => { if (!URL.canParse(origin)) return false; const { protocol, hostname } = new URL(origin); @@ -112,9 +109,7 @@ const isTrustworthyOrigin = (origin: string) => { ); }; -/** - * Windows needs none: Chromium already hands WebAuthn to Windows Hello there. - */ +// Chromium already delegates to Windows Hello. const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => platform === "darwin" ? macPasskeyBackend : undefined; @@ -168,9 +163,7 @@ const make = Effect.gen(function* () { // Only the guest's main frame runs the bridge preload. const frame = event.senderFrame; if (!frame || frame !== guest.mainFrame) return notAllowed; - // The page shares a JS world with the preload, so nothing it sends can - // be trusted for the origin. Read the committed origin before any await: - // the frame object outlives a navigation. + // The page shares the preload's JS world; read the frame's committed origin before any await/navigation. const origin = frame.origin; if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { return notAllowed; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 4059468da22e..4cb5987cdeb8 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -197,11 +197,7 @@ const navigationDirectionForButton = (button: number): "back" | "forward" | null return null; }; -// Chromium routes thumb-button history navigation to the *focused* WebContents, -// so hovering this guest without focusing it sends the host app's router back -// instead of the preview. Suppress Chromium's default here and drive this tab's -// history explicitly so the buttons always navigate the browser the pointer is -// over — never the host app. +// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. const suppressNavigationButton = (event: MouseEvent): void => { if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; event.preventDefault(); @@ -391,11 +387,7 @@ function toStackFrame(frame: { }; } -/** - * `getElementContext` walks the inspected page's React internals, and some pages leave it - * pending forever. Without a bound, the whole submit chain stalls and the overlay sits on - * "Capturing…". - */ +/** React-internals lookups can remain pending forever; bound them so submit cannot stall the overlay. */ function withCaptureTimeout(promise: Promise, millis: number): Promise { let timer: ReturnType | undefined; return Promise.race([ @@ -408,11 +400,7 @@ function withCaptureTimeout(promise: Promise, millis: number): Promise { const base = { pageUrl: location.href, @@ -1399,8 +1387,7 @@ function startAnnotation(sendEnabled: boolean): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; - // The element context lookup can take up to its timeout, and the user can - // keep editing meanwhile; the annotation must describe what they submitted. + // Snapshot the submitted comment before context lookup lets the user edit it. const submittedComment = comment.value.trim(); const submittedRegions = [...regions]; const submittedStrokes = [...strokes]; @@ -1421,8 +1408,7 @@ function startAnnotation(sendEnabled: boolean): void { }), ) .then((elements) => { - // The overlay may have been cancelled or replaced while the capture - // ran. A late submit must not deliver into the next pick's listener. + // A late submit must not reach a replacement pick's listener. if (finished) return; const annotation: PreviewAnnotationPayload = { id: nextId("annotation"), @@ -1461,8 +1447,7 @@ function startAnnotation(sendEnabled: boolean): void { root.addEventListener("keydown", (event) => { const submission = event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; - // Keep this in the bubble phase so editor inputs receive the event before - // it is isolated from listeners installed by the inspected page. + // Bubble phase lets editor inputs handle events before hiding them from the page. event.stopImmediatePropagation(); if (!submission) return; event.preventDefault(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 4524b0d017b6..5b9fb3c13bc3 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -11,11 +11,7 @@ import { type RecordingKeyPress, } from "./RecordingInput.ts"; -/** - * Chromium's capture cursor uses native window bounds, which do not follow a - * webview's CSS placement or scale. Draw it in the guest's coordinate space - * while recording, and make the native cursor transparent to avoid two cursors. - */ +/** Draw in guest coordinates; Chromium's native cursor ignores webview CSS placement/scale. Hide it to avoid duplicates. */ export function installRecordingCursor( document: Document, window: Window, diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 182b6a505d16..572a290e2b66 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -6,7 +6,6 @@ function parseWebPreferences(input: string): Record { const out: Record = {}; for (const pair of input.split(",")) { if (pair !== pair.trim()) { - // Electron's parser doesn't trim; surface the bug as undefined-key. out[pair] = pair.split("=")[1]; continue; } @@ -21,9 +20,6 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { - // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so - // `contextIsolation="no"` would silently leave isolation ENABLED. Lock - // the values to `"true"` / `"false"` so the parser does the right thing. for (const value of Object.values(parsed)) { expect(value).toMatch(/^(true|false)$/); } diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts index 4236b78293ab..4dc9d9aa6b73 100644 --- a/apps/desktop/src/preview/WebviewPreferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -1,9 +1,3 @@ -/** - * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical - * when paired with `contextIsolation=false` — without sandbox, the preload - * has full Node access (`require`, `fs`, `child_process`, ...) and that - * `require` would land on the page's shared `globalThis`, giving any - * third-party page in the preview full Node + IPC access to the host. - */ +/** sandbox:true is required with contextIsolation:false or the shared page global would expose Node and IPC. */ export const PREVIEW_WEBVIEW_PREFERENCES = "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1c131681295f..433e8127a509 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -37,8 +37,7 @@ export interface DesktopSettings { readonly updateChannelConfiguredByUser: boolean; readonly wslBackendEnabled: boolean; readonly wslDistro: string | null; - // Changing this requires a desktop restart because the pool's primary spec is - // chosen once at layer init. + // Requires restart because the pool chooses its primary spec at layer initialization. readonly wslOnly: boolean; } @@ -466,7 +465,6 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp }); /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 248fb9b6f848..cccc0585024a 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -221,7 +221,7 @@ function decodeSecretBytes( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 3fdec7932366..c80fe9b87cd6 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -360,11 +360,6 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return { - // Concurrent, not sequential: these two probes are independent (only their - // results are combined below) and each spawns its own PowerShell. Run in - // series they sit at offset 0 of desktop.startup, before anything else, and - // launch traces measured them at 2718ms then 2066ms — the entire 4.8s - // startup span, of which desktop.bootstrap is ~30ms. const [noProfile, profile] = yield* Effect.all( [ readWindowsEnvironment(["PATH"], { loadProfile: false }), @@ -432,9 +427,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", - // mise's record of the PATH entries it added. It must travel with the - // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper - // (`exec mise x claude -- claude`) that then re-runs itself forever. + // Forward mise's PATH delta too, or mise x can rebuild PATH into a recursive same-named wrapper. "__MISE_DIFF", ] as const; for (const name of shellPreferredEnvNames) { @@ -452,9 +445,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", - // CA trust set in the shell profile. Without it, a Node or OpenSSL build - // that has no bundled roots (a shared-OpenSSL node on PATH) cannot verify - // TLS, so provider updaters and agents fail to reach their registries. + // Shell CA settings are required by Node/OpenSSL builds without bundled roots for registry TLS. "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", @@ -465,8 +456,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - // Locale variables form one precedence group: LC_ALL can override an inherited - // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. + // Hydrate locale variables together; inherited LC_ALL can override LANG or LC_CTYPE. if ( config.platform === "darwin" && LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) @@ -478,11 +468,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - // GUI launches inherit no locale from launchd, so spawned agents land in the C - // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported - // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale - // available on those releases. Leaving LANG unset keeps C-stable collation and - // formatting, so output parsing is unaffected. + // launchd lacks locale; UTF-8 LC_CTYPE fixes pbcopy's MacRoman decoding without changing C formatting or collation. if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { config.env.LC_CTYPE = FALLBACK_LC_CTYPE; } @@ -522,7 +508,7 @@ const installShellEnvironment = ( return Effect.succeed(false); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index f62669a39bf3..20f56cc19442 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -6,10 +6,7 @@ import * as Schema from "effect/Schema"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; -/** - * `id` is the CGWindowNumber on macOS and the HWND on Windows, which is what - * the capture backends key on. - */ +/** id is CGWindowNumber on macOS or HWND on Windows, matching the capture backend. */ export type ActiveWindow = { readonly platform: "macos" | "windows"; readonly id: number; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 39a7dddf5de7..dc3aa093f9cf 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -798,7 +798,6 @@ it.effect.each(["darwin", "win32"] as const)( for (const finish of heldFlights) finish(); yield* Fiber.join(landing); - // Landing releases the guard even before the first capture is acknowledged. yield* Effect.promise(fixture.trigger); assert.equal(fixture.state.snapshots, 2); assert.lengthOf(fixture.readyIds, 2); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 164c97b4f22f..e2f1c6e82a36 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -211,7 +211,6 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError { send(event, { ScreenshotCaptured: { path: "/another-app.png" } }); send(event, { ScreenshotCaptured: { path: screenshot.path } }); } - // The event may beat the command reply. send(socket, { Ok: "Handled" }); } else send(socket, { Ok: "Handled" }); }; diff --git a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts index 374745c96e60..f0ac7b9c1bba 100644 --- a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts +++ b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts @@ -1,5 +1,4 @@ -// Runs in a forked Node-mode child so the native screenshot call cannot crash -// or stall the main process. +// Isolate native screenshot crashes and stalls in a Node-mode child. import type { RegionSnapShotRequest, RegionSnapShotResult } from "./RegionSnapShot.ts"; process.once("disconnect", () => process.exit(0)); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index 3361aafc3c78..9021c8904e3f 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -172,8 +172,7 @@ async function readCapturedWindowAccessibility( .catch(() => { progress.flatComplete = true; }); - // A decorated screenshot contains more than the accessibility client area. Keep its - // frame origin/scale so element coordinates include the actual decoration offset. + // Preserve the screenshot frame origin and scale so accessibility coordinates include decorations. const sourceBounds = matchMode === "wayland" && active.clientBounds ? active.bounds diff --git a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts index dd1474b88505..542e3f13c08d 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts @@ -49,8 +49,7 @@ export function startSnapShotAccessibilityProcess(workerPath: string): Accessibi let worker: NodeChildProcess.ChildProcess; try { worker = NodeChildProcess.fork(workerPath, ["read"], { - // Electron defaults to its Helper executable on macOS, which does not - // share the main app's Accessibility grant checked during setup. + // Electron's default macOS Helper executable lacks the main app's Accessibility grant. execPath: process.execPath, env: { ...process.env, diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index a8ca237009bb..4b6ce04a1bf2 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -310,8 +310,7 @@ export class SnapShotTransition { } } if (this.waitForCompositorFrame) { - // Cover the source with a composited snapshot before the caller reveals T3. - // Decoding and renderer animation frames alone can leave a transparent gap. + // Renderer frames alone can leave a transparent gap; composite the snapshot before revealing T3. await Promise.all( overlays.map(async (overlay) => { if (this.active !== active || overlay.window.isDestroyed()) return; @@ -408,8 +407,7 @@ export class SnapShotTransition { ); if (this.active !== active || overlay.window.isDestroyed()) return; if (this.waitForCompositorFrame) { - // Read back one pixel after preparation to flush queued compositor work - // before the animation clock starts on any of the display surfaces. + // Read back one pixel to flush compositor work before starting any display's animation clock. await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 }); } return overlay; diff --git a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts index eb02eb7a03d1..ca4165626b26 100644 --- a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts +++ b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts @@ -4,8 +4,7 @@ const WINDOW_ANIMATIONS_DISABLED = "wm-window-animations-disabled"; /** Show capture feedback without Aura's extra 200 ms fade/scale on transparent windows. */ export function showWindowsCaptureOverlay(window: Electron.BaseWindow): void { - // Chromium reads this switch synchronously inside Show(). Restore it before returning - // so other windows and CSS animations keep their normal behavior. + // Show() reads the switch synchronously; restore it before other windows or animations can be affected. const alreadyDisabled = Electron.app.commandLine.hasSwitch(WINDOW_ANIMATIONS_DISABLED); if (!alreadyDisabled) Electron.app.commandLine.appendSwitch(WINDOW_ANIMATIONS_DISABLED); try { diff --git a/apps/desktop/src/snapShot/linuxCaptureSession.ts b/apps/desktop/src/snapShot/linuxCaptureSession.ts index c35fee18db45..59fe40b7c521 100644 --- a/apps/desktop/src/snapShot/linuxCaptureSession.ts +++ b/apps/desktop/src/snapShot/linuxCaptureSession.ts @@ -4,8 +4,7 @@ import * as NodeURL from "node:url"; import type { SnapShotKeyChord } from "@t3tools/contracts"; import { nativeImage } from "electron"; -// Linux helpers that need no D-Bus. Keep this module free of dbus-next so the main -// process can answer "which desktop is this" and read a PNG without loading it. +// Keep dbus-next out of desktop detection and PNG reads in the main process. export const HYPRLAND_CAPTURE_ACTION = "capture-window"; export const NIRI_CAPTURE_PATH = "/com/t3tools/SnapShot"; diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 958e017299e0..28bd301c557e 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -520,8 +520,7 @@ export function findAccessibleWindow< const browserMatch = findBrowserTitleMatch(windows, titles, captured, matchMode, matchesBounds); if (browserMatch) return browserMatch; } - // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped - // lookup can accept the one window whose bounds match; size-only guesses cannot. + // GTK4 frames may be unnamed; accept only a PID-scoped unique bounds match, never size alone. if (!options.allowUntitledUniqueBounds) return undefined; const boundsMatches = windows.filter( (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 61b104b4ac62..eac148ae4d0f 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -125,7 +125,7 @@ const makePasswordPrompt = ( prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 296b81339e80..7442282aa15a 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -218,7 +218,7 @@ const failPending = ( error: DesktopSshPasswordPromptRequestError, ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( options: DesktopSshPasswordPromptsOptions = {}, ) { diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 5e4b1de81b83..8800c4f42833 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -141,7 +141,7 @@ function sampleInterval( return LIVE_SAMPLE_INTERVAL; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const electronApp = yield* ElectronApp.ElectronApp; const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 49acc3f86a48..253a015d8ec9 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -22,9 +22,7 @@ import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { makeHarness } from "./updatesTestHarness.ts"; -// The remote flow hops between the test runtime's fibers and the updater's -// runPromise-driven event handlers, so settling needs real microtask turns, -// not just fiber yields. +// Updater runPromise handlers need real microtasks between test-runtime fiber yields. const settle = Effect.gen(function* () { for (let i = 0; i < 20; i += 1) { yield* Effect.yieldNow; @@ -326,8 +324,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("retries a download refused while the check still holds the reservation", () => { - // electron-updater emits update-available from inside checkForUpdates, - // before the check action releases its reservation. const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); @@ -359,9 +355,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("waits for the download reservation before reporting prepared", () => { - // update-downloaded fires from inside downloadUpdate. If the flow - // reported "installing" right then, install would be refused for the - // held reservation after the irrevocable terminal already went out. const releaseDownload = Deferred.makeUnsafe(); const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb1b86c07754..cb606d8fbd25 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -175,11 +175,7 @@ export const listen: Effect.Effect< const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; - // A state event can land while the action that produced it still holds - // the reservation (e.g. "available" before the check releases), so a - // forked action can be refused with no later state event to retry on. - // Rejected actions re-enqueue their state here after a short pause so - // the step runs again once the reservation is free. + // State events can precede reservation release; re-enqueue refused actions so they retry after release. const retries = yield* Queue.unbounded(); const retryLater = (state: DesktopUpdateState) => Effect.sleep(ACTION_RETRY_DELAY).pipe( @@ -416,11 +412,7 @@ export const listen: Effect.Effect< result.state.downloadedVersion === claim.prepared.downloadedVersion && (yield* updates.isInstallActive) ) { - // Another install (local, or an earlier remote request) already owns - // the shutdown and will relaunch the app on the same downloaded - // version. This commit joins it: no failure marker, and the client - // proves the handoff the same way, by transport loss then the - // target version on reconnect. + // Join the existing install; reconnect and target-version checks prove its handoff. yield* logInfo("remote update commit joining an in-progress install", { requestId: commit.requestId, }); @@ -433,9 +425,7 @@ export const listen: Effect.Effect< } return; } - // A successful install tears down this backend. Do not send a success - // marker from the old process: transport loss followed by the target - // version is the only proof that the handoff succeeded. + // Installation kills this backend; only transport loss followed by the target version proves handoff. }).pipe( Effect.ensuring(clearActiveRequest(commit.requestId)), Effect.catchCause((cause) => diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index ae3c2588fab2..5265d65523ba 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -64,9 +64,7 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, - // Left unvalidated on purpose: a malformed release-notes payload must never - // fail the decode and block the update state transition. The shape is - // validated defensively in normalizeDesktopUpdateReleaseNotes. + // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), }); @@ -335,7 +333,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; @@ -364,8 +362,7 @@ export const make = Effect.gen(function* () { ); const stateChanges = yield* PubSub.sliding(16); - // Makes ref writes + publishes atomic against subscribe, so a snapshot - // never overlaps with the first change a subscriber receives. + // Serialize writes and publishes against subscription so snapshots cannot overlap the first change. const stateMutex = yield* Semaphore.make(1); const emitState = Ref.get(updateStateRef).pipe( @@ -399,8 +396,7 @@ export const make = Effect.gen(function* () { ), ); - // The .deb carries electron-builder's resources/package-type marker. - // electron-updater reads the same file and installs updates with dpkg. + // electron-updater uses this marker to install .deb updates with dpkg. const isDebPackage = environment.platform === "linux" && environment.isPackaged ? yield* fileSystem @@ -597,8 +593,7 @@ export const make = Effect.gen(function* () { ), ); - // A failed or interrupted install brings no updated backend, so a later - // quit must release the tunnel. + // Failed or interrupted installs leave no replacement backend; later quits must release the tunnel. const removeUpdateRestartMarker = fileSystem .remove(updateRestartMarkerPath, { force: true }) .pipe(Effect.ignore); @@ -742,11 +737,7 @@ export const make = Effect.gen(function* () { yield* writeUpdateRestartMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); - // Stop every backend in the pool, not just the primary. With parallel - // WSL + Windows backends, leaving the WSL instance up means - // quitAndInstall's app.quit() exits before the pool's scope cascade - // has a chance to run its stop finalizer, so the WSL child gets - // hard-killed by the OS instead of receiving SIGTERM + grace. + // Stop pooled backends before quitAndInstall can outrun their scope finalizers. const instances = yield* pool.list; // stop clears desiredRunning, so retain the recovery set beforehand. const runningInstances = yield* Effect.filter(instances, (instance) => diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 008a83eab03d..028e42fa6e3b 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -81,8 +81,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("drops releases from other trains before grouping on nightly", () => { - // electron-updater's full changelog is "every version above the running - // one", and preview sorts above nightly, so the preview cuts come first. const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index ef6d217299d0..69d710ff7b26 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -30,8 +30,6 @@ const HTML_ENTITY_REPLACEMENTS: Readonly> = { }; function decodeCodePoint(codePoint: number, entity: string): string { - // String.fromCodePoint throws RangeError outside the valid Unicode range, and - // Number.isFinite alone lets oversized values (e.g. �) through. if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return `&${entity};`; } @@ -124,12 +122,7 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } -/** - * With `fullChangelog` on (nightly), electron-updater collects every GitHub - * release whose version is semver-greater than the running one, whatever train - * it belongs to; a maintainers' `-preview.` cut sorts above every `-nightly.` - * of the same base version and would lead the list. - */ +/** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index f7c65b35820c..1d9588315559 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -11,12 +11,7 @@ export type RemoteDesktopUpdateStep = readonly reason?: string; }; -/** - * The caps are what stop a check -> up-to-date -> check loop and endless - * download retries; they are counts rather than booleans because a state event - * raced by the local 4-minute poller can re-show an already-handled status - * once. - */ +/** Bound retries with counts: background poller races can repeat an already-handled status once. */ export interface RemoteDesktopUpdateAttempts { readonly checks: number; readonly downloads: number; @@ -46,9 +41,7 @@ export function nextRemoteDesktopUpdateStep( reason: disabledReason ?? "Automatic updates are disabled on this machine.", }; } - // An install of this version already failed to apply here. Installing it - // again from afar would stop the app's threads for nothing, so only the user - // on this machine retries it. + // Reinstalling a failed version disrupts threads without updating; retries require the local user. if ( state.notAppliedVersion !== undefined && (state.downloadedVersion ?? state.availableVersion) === state.notAppliedVersion @@ -59,11 +52,7 @@ export function nextRemoteDesktopUpdateStep( reason: `Update ${state.notAppliedVersion} did not apply on this machine. Retry it from the desktop app there.`, }; } - // Mirror installDownloadedUpdate's own admission rule exactly, so the run - // never reports an install that the updater then refuses. A download - // survives an unrelated background updater error (errorContext null) and - // a previous failed install (errorContext "install"); it does not survive - // a check or download error, which fall through to the error branch. + // Match install admission, including retained downloads after unrelated background or earlier install errors. if (isInstallableDesktopUpdateState(state)) { return { action: "install" }; } @@ -80,9 +69,7 @@ export function nextRemoteDesktopUpdateStep( } return { action: "download" }; } - // "up-to-date" and "error" are retained from earlier/background checks, - // so before this run has issued its own check they are stale, not - // terminal: the whole point of a remote request is to look again. + // Background up-to-date/error states are stale until this request has issued its own check. if (state.status === "up-to-date") { if (attempts.checks === 0) { return { action: "check" }; @@ -109,11 +96,7 @@ export function nextRemoteDesktopUpdateStep( return { action: "check" }; } -/** - * Updater messages are plain strings and may be blank; a blank reason would - * fail encoding and silently drop the terminal report, leaving the server to - * wait for its timeout. - */ +/** Blank reasons fail encoding and drop the terminal report, forcing the server to time out. */ export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); return trimmed ? trimmed : undefined; diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 1b1a1ec1efa9..d79a374a668a 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,10 +1,7 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// They share nightly's branding but are packaged without an update feed (see -// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the channel -// a preview install reports is cosmetic: it never checks for updates and no -// updater feed ever lists a preview release. +// Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index aeaca8350709..7c70656c1ac4 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -106,14 +106,13 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { return; } - // The result dialogs need no main window. Opening one before the backend - // answers strands the renderer on a failed auth bootstrap. + // Opening a window before backend confirmation can strand it on failed auth bootstrap. const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -143,10 +142,7 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - // Chromium already pastes as plain text for this chord, so the accelerator - // needs nothing from the menu: the composer and the terminal each arm - // themselves from the same keydown. Routing it through the renderer anyway - // lands a second, injected paste and doubles the text. + // Chromium and the renderer already handle this chord; injecting another paste duplicates text. const pasteAsTextClick = ( _item: Electron.MenuItem, _window: Electron.BaseWindow | undefined, @@ -238,11 +234,7 @@ export const make = Effect.gen(function* () { { label: "View", submenu: [ - /* - Not the reload, DevTools or zoom roles: those act on the focused - webContents, so with a browser page focused they reload or zoom - the guest page and the app UI appears stuck. - */ + /* Native reload/DevTools/zoom roles target focused guests; these commands must target the app. */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, { label: "Force Reload", @@ -274,8 +266,7 @@ export const make = Effect.gen(function* () { ] : []), { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, - // The numeric keypad sends its own key codes, so the number-row - // accelerators above never match it. + // Keypad codes do not match number-row accelerators. { label: "Actual Size", accelerator: "CmdOrCtrl+num0", diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index dbdde033cae7..8b6ab7c7d8a5 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -585,8 +585,6 @@ describe("DesktopWindow", () => { }), ); - // Ctrl+wheel bypasses the menu accelerators, so it has to take the same - // zoom step, preview restore included, from the window's zoom-changed event. it.effect("zooms the app from Ctrl+wheel like the menu does", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 297c67ddace0..6270f80a4a7e 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -37,8 +37,7 @@ import { makeQuitShortcutHandler } from "./QuitHold.ts"; import { copyContextMenuImage } from "./ContextMenuImage.ts"; const TITLEBAR_HEIGHT = 40; -// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS -// buttons are 14 points tall and do not scale with the renderer's zoom. +// Match --workspace-topbar-height in apps/web/src/index.css; native macOS buttons stay 14 points at any zoom. const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; const MACOS_WINDOW_BUTTON_RADIUS = 7; @@ -109,9 +108,7 @@ export class DesktopWindow extends Context.Service< readonly dispatchSnapShotEvent: ( event: DesktopSnapShotEvent, ) => Effect.Effect; - // The Electron `zoomIn`/`zoomOut` menu roles act on whichever webContents - // has keyboard focus, so with an embedded preview WebContentsView (or - // DevTools) focused they zoom the guest page instead of the app UI. + // Electron zoom roles target focused guests; zoomMain must always target the app. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; @@ -222,8 +219,7 @@ function concealPendingQuitWindow( if (window.isFullScreen()) { window.setFullScreen(false); } - // Electron implements window opacity on macOS and Windows. Linux keeps the - // release-gated quit behavior but cannot make the pending window disappear. + // Linux lacks Electron window opacity; release-gated quits remain visible there. window.setOpacity(0); } @@ -286,7 +282,7 @@ function bindFirstRevealTrigger( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopState = yield* DesktopState.DesktopState; @@ -315,11 +311,7 @@ export const make = Effect.gen(function* () { } }); - // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", - // which during WSL-only boot is the connecting splash. The splash is never - // registered via setMain, so it must be treated as "no real main window" -- - // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never - // open (or retry) the real main. + // Electron's first-window fallback can select the unregistered connecting splash; exclude it so main creation retries. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -341,9 +333,7 @@ export const make = Effect.gen(function* () { direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); if (environment.platform === "darwin") syncMacosWindowButtons(window); - // Chromium pushes the new level down to embedded guests, which would zoom - // the previewed page along with the app UI. The preview browser keeps its - // own zoom, so put each guest back where the preview left it. + // App zoom propagates to guests; restore each preview's own zoom. yield* previewManager.reapplyZoom(); }); @@ -394,18 +384,13 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, - // Chromium throttles hidden renderers: timers coalesce and rAF stops, - // which stalls first paint. Boot unthrottled; the first-reveal trigger - // re-enables throttling so a hidden or minimized window goes back to - // being cheap after it has been shown once. + // Hidden-renderer throttling stalls first paint; re-enable it after the first reveal. backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, - // A preview guest's fullscreen request is mirrored onto this embedder, - // which would otherwise put the whole window into OS fullscreen. With - // both sides opted out the page fills its webview and the window stays. + // Guest fullscreen propagates to the embedder; both must opt out to keep it inside the webview. disableHtmlFullscreenWindowResize: true, }, }); @@ -541,8 +526,7 @@ export const make = Effect.gen(function* () { contents.on("context-menu", (event, params) => { event.preventDefault(); if (contents.isDestroyed() || ownerWindow.isDestroyed()) return; - // Native editing roles act on the focused contents, which may still be - // the host renderer when the user right-clicks inside a browser guest. + // Right-clicking a guest may leave native editing roles targeting the host renderer. contents.focus(); const menuTemplate: Electron.MenuItemConstructorOptions[] = []; @@ -635,9 +619,7 @@ export const make = Effect.gen(function* () { } }); - // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the - // close-terminal shortcut can outlive the terminal that handled its first - // press, so reject repeats before they reach the native window accelerator. + // Terminal-close repeats can outlive the terminal; reject them before the native close-window accelerator. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -655,8 +637,7 @@ export const make = Effect.gen(function* () { window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, - // Keep the transparent window focused until the physical shortcut is - // released so its remaining repeats cannot reach the next app. + // Hold focus until shortcut release so remaining repeats cannot reach the next app. concealWindow: () => concealPendingQuitWindow(window), quit: () => { void runPromise(electronApp.quit); @@ -673,9 +654,7 @@ export const make = Effect.gen(function* () { window.webContents.on("input-event", (_event, input) => { if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); }); - // Ctrl+wheel never reaches the menu accelerators: Chromium reports it here - // and leaves applying the zoom to the app. A preview guest under the - // pointer emits this on its own webContents and keeps its own zoom. + // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. window.webContents.on("zoom-changed", (_event, direction) => { if (window.isDestroyed()) return; runFork(zoomWindow(window, direction)); @@ -793,8 +772,6 @@ export const make = Effect.gen(function* () { details.reason === "oom" || details.reason === "killed" || details.reason === "abnormal-exit"; - // Recovery attempts are bounded so a renderer that dies immediately on - // boot cannot reload-loop forever. runFork( Effect.gen(function* () { @@ -959,8 +936,7 @@ export const make = Effect.gen(function* () { const send = Effect.sync(() => { if (!targetWindow.isDestroyed()) targetWindow.webContents.send(channel, payload); }); - // The renderer must learn about the event even when another process refuses to - // yield the foreground, so send first and treat the reveal as best effort. + // Send before best-effort reveal; another process may refuse to yield foreground focus. const dispatch = reveal ? send.pipe(Effect.andThen(electronWindow.reveal(targetWindow).pipe(Effect.ignoreCause))) : send; @@ -1034,8 +1010,7 @@ export const make = Effect.gen(function* () { }), runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { yield* Effect.annotateCurrentSpan({ command }); - // The registered main window, never the focused one: with an OAuth popup - // focused, Reload would otherwise reload the popup mid sign-in. + // A focused OAuth popup must not receive the app's Reload command. const window = yield* electronWindow.main; if (Option.isNone(window) || window.value.isDestroyed()) return; const webContents = window.value.webContents; diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index b87ae9ceab29..f7db33f17ed5 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -4,11 +4,9 @@ import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contr export const QUIT_HOLD_DURATION_MS = 1200; export const QUIT_DOUBLE_PRESS_MS = 500; -// "Still held" is proven by auto-repeat keydowns, not by the absence of a -// release: macOS suppresses a letter keyUp while the command key is down, so a -// tap release can go completely unseen and a release-based timer would quit -// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after -// repeats stop so they cannot reach the next app. +// Auto-repeat keydowns prove the hold: macOS drops a letter keyUp while Command is down, +// so a release-based timer would quit anyway. Once held, quitting waits for Q keyUp or a +// quiet grace period so trailing repeats cannot reach the next app. const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. @@ -46,9 +44,7 @@ export function makeQuitShortcutHandler( let lastPressAt = 0; let lastRepeatAt = 0; let repeatCadenceMs = 0; - // Incremented when a press is superseded or explicitly cancelled. A plain - // key release does not invalidate its pending mode read: a direct-mode - // press must still quit after that read settles. + // A key release must not invalidate a pending direct-mode read; only superseding/cancelled presses do. let generation = 0; const clearWatchdog = () => { @@ -125,8 +121,7 @@ export function makeQuitShortcutHandler( if (!modifierDown || input.alt || input.shift || key !== "q") { if (key === modifierKey && !input.alt && !input.shift) return; - // Keep this separate from release(), which also runs when a fresh Q - // keydown follows a keyUp that macOS did not deliver. + // release() also runs on fresh keydown after a missing macOS keyUp. if (!input.isAutoRepeat) { lastPressAt = 0; release(); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index d1f61ecc0bde..e77d896be246 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -15,8 +15,6 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; -// Exported so callers that parse pool ids (e.g. the pickFolder IPC handler in -// ipc/methods/window.ts) reference the same prefix this module produces. export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; const MAX_TCP_PORT = 65_535; @@ -43,9 +41,7 @@ const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => const buildLabel = (distro: string | null): string => distro === null ? "WSL (default distro)" : `WSL (${distro})`; -// Loopback-only port scan starting one above the primary's port. The -// WSL backend is reachable via 127.0.0.1 from Windows (wslhost -// auto-forwards), so we only need to verify the IPv4 loopback can bind. +// WSL auto-forwards loopback ports, so only IPv4 loopback availability matters. const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( startPort: number, ): Effect.fn.Return { @@ -69,11 +65,7 @@ export const layer = Layer.effect( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const net = yield* NetService.NetService; - // Serialize reconcile so the bootstrap fork and the IPC handlers - // (setWslBackendEnabled, setWslDistro) can't interleave. Without - // this, two reconciles could both observe "no WSL instance - // registered" between their pool reads and both call startNew - // with different distros, leaving the loser stranded. + // Serialize bootstrap and IPC reconciles so concurrent reads cannot register competing WSL instances. const reconcileMutex = yield* Semaphore.make(1); const preflightErrorRef = yield* Ref.make(Option.none()); @@ -169,11 +161,7 @@ export const layer = Layer.effect( const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); - // In wsl-only mode the pool's primary IS the WSL backend (see - // DesktopBackendConfiguration.resolvePrimary), so the - // orchestrator skips registering a parallel "wsl:" - // secondary. Without this skip we'd spin up two WSL processes - // on the same distro for users who explicitly asked for one. + // In wsl-only mode WSL is already primary; do not start a second backend on the same distro. const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; const targetId = shouldRun ? Option.some(resolveTargetInstanceId(settings.wslDistro)) @@ -210,8 +198,7 @@ export const layer = Layer.effect( } if (Option.isSome(targetId)) { - // Pre-warm the WSL VM before registering so the readiness probe doesn't - // race wsl.exe's first-spawn cold start. + // Pre-warm WSL so readiness cannot race its cold start. yield* wslEnvironment.preWarm(settings.wslDistro); yield* startNew({ distro: settings.wslDistro }); } diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 711733cb36b5..67549f6b8a54 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -39,8 +39,7 @@ const posixShellRunner = (() => { const runShell = (script: string) => { if (posixShellRunner === null) throw new Error("no POSIX shell runner available"); - // The install script arrives on stdin in production too, which is what lets - // its own /proc scan not match itself. + // Pass the script on stdin, as production does, to avoid matching its own /proc scan. const result = NodeChildProcess.spawnSync( posixShellRunner.file, [...posixShellRunner.args, "-s"], @@ -54,10 +53,7 @@ const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const awaitHolder = (condition: string) => `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; -// Holds a cache busy the way a running server does: the holder's argv[0] is the -// runtime entry, which the prune and install scripts look for in /proc cmdline. -// It reads a pipe the script keeps open on fd 7, so it exits with the script -// instead of outliving it on a timer. +// Use runtime-entry argv[0] for /proc detection; an open pipe keeps it alive only while this script runs. const holdRuntimeBusy = (entry: string) => [ `exec 7> >(exec -a ${entry} cat >/dev/null 2>&1)`, @@ -124,8 +120,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed const archivePath = `${work}/wsl-runtime.tar.gz`; const archiveSha = readField(result.stdout, "archiveSha"); const runtimeId = `sha256-${archiveSha}`; - // The script reads $HOME, and WSL does not inherit the parent process's - // environment, so the home override rides in the script itself. + // WSL does not inherit HOME; embed the fixture's override in the script. const installScript = (archive = archivePath, sha = archiveSha) => [ `HOME=${sh(`${work}/home`)}`, @@ -288,8 +283,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`, fixture.installScript(), "T3CODE_INSTALL_SCRIPT", - // Both racers run the same file, and neither file path contains the - // runtime root, so the script's own /proc scan cannot see them. + // Keep runtime roots out of both racers' argv so their /proc scans cannot match themselves. 'sh "$work/install.sh" > "$work/first.out" 2>&1 &', "first=$!", 'sh "$work/install.sh" > "$work/second.out" 2>&1 &', diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 12d66660ab9c..5017793d0735 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -89,9 +89,7 @@ export class DesktopWslEnvironment extends Context.Service< DesktopWslEnvironment, { readonly isAvailable: Effect.Effect; - // Best-effort enumeration for renderer UX. Backend health checks must use - // probeDistros so a transient command failure is not mistaken for a - // successful empty installation. + // UX-only best effort; health checks need probeDistros to distinguish failures from an empty installation. readonly listDistros: Effect.Effect; readonly probeDistros: Effect.Effect; readonly preWarm: (distro: string | null) => Effect.Effect; @@ -100,11 +98,7 @@ export class DesktopWslEnvironment extends Context.Service< windowsPath: string, ) => Effect.Effect>; readonly getUserHome: (distro: string | null) => Effect.Effect>; - // The orchestrator uses this for the WSL backend's httpBaseUrl so the - // renderer can reach it without relying on wslhost's localhost→WSL - // automatic forwarding, which is flaky in practice (the backend can be - // listening for 30+ seconds before wslhost starts forwarding 127.0.0.1:port - // to WSL-side localhost). + // Use the distro IP to avoid unreliable wslhost localhost forwarding. readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly prepareRuntime: ( distro: string | null, @@ -177,8 +171,7 @@ const buildWslNodeEnvPreamble = ( ensure_remote_node_path || true `; -// wsl.exe re-escapes args before forwarding them to the Linux side, which -// mangles quotes inside `bash -lc " ", + "

The moon garden opens at dusk

", + '', + ].join(""), + }); + expect(preview.consoleMessages).toContainEqual({ + level: "log", + text: appearance === "light" ? "false rgb(24, 24, 27)" : "true rgb(250, 250, 250)", + }); + } + }).pipe(Effect.provide(layerHtmlRender(executable))); + }), + ); + it.live("keeps every local file but the page itself out of the browser", (ctx) => Effect.gen(function* () { const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index 810143c75a79..44ea627ceab7 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -77,11 +77,8 @@ export class HtmlRenderViewportHeightError extends Schema.TaggedError|$)|]*(?:>|$)([\s\S]*?)(?:<\/style\s*>|$)|\sstyle\s*=\s*(?:"([^"]*)(?:"|$)|'([^']*)(?:'|$)|([^\s>]+))/gi; // The lookbehind skips base64 runs, such as an inlined image that happens to contain "1vh". @@ -181,10 +178,7 @@ const dataUriPrefix = (path: string) => const latin1 = (bytes: Uint8Array, start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end)); -/** - * Whether file bytes are an image, whatever the file is named, so a symlink or - * renamed file cannot carry other data, such as a secret, into a page. - */ +// Validate bytes, not filenames, so renamed files or symlinks cannot inline secrets. const isImageBytes = (bytes: Uint8Array) => { const head = latin1(bytes, 0, 12); if ( @@ -272,8 +266,7 @@ const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (h if (pageBytes > MAX_PAGE_BYTES) { return yield* new HtmlRenderPageTooLargeError({ sizeBytes: pageBytes }); } - // Files can grow after `stat`. Each read stops one byte past the image - // limit, and reading stops once the images read so far cannot fit the page. + // Files can grow after stat; bound each read and stop when accumulated images cannot fit. let readBytes = 0; const images = yield* Effect.forEach( files.filter((file) => file.size !== undefined), @@ -345,8 +338,7 @@ const make = Effect.gen(function* () { FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >(); const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS); - // Chrome's sandbox stays on unless the operator explicitly turns it off. - // Chrome also refuses it as root, where that opt-out is the only way to run. + // Keep Chrome sandboxed unless explicitly disabled; root must opt out to run. const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); @@ -492,6 +484,7 @@ const make = Effect.gen(function* () { html: inlined.html, width, urlFragment: htmlRenderThemeFragment(theme), + appearance, }).pipe(Effect.provideContext(services)), ); return { diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index d9339b9c4694..5562d43826c7 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -1,3 +1,4 @@ +import type { ThemeAppearance } from "@t3tools/shared/themePalettes"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -45,14 +46,8 @@ const MAX_CONSOLE_TEXT_CHARS = 500; const VIEWPORT_HEIGHT = 800; const MAX_CAPTURE_HEIGHT = 4_000; const CAPTURE_TIMEOUT = "20 seconds"; -// Chrome refuses local files to every web page, frame, worker, and popup, so a -// page cannot read files the agent's provider withholds; local images reach it -// already inlined as data URIs. `.localhost` keeps it a secure context that may -// still load plain-http resources, and the request never leaves the browser. -// All of the browser's traffic goes through `publicProxy`, which only reaches -// public addresses, so a page cannot reach this machine's local network. The -// main frame also stays on the page and the browser opens no popups, so a -// capture always shows the page itself. +// Chrome blocks file access; images arrive inlined. Browser-only .localhost permits secure contexts and HTTP resources. +// publicProxy blocks local networks; pinning the main frame and forbidding popups keeps captures on the page. const PAGE_ORIGIN = "http://t3-page.localhost"; const PAGE_URL = `${PAGE_ORIGIN}/page.html`; const PAGE_NAME = "page.html"; @@ -60,9 +55,7 @@ const MEASURE_CONCURRENCY = 3; const textEncoder = new TextEncoder(); -/** - * Node's encoder, since pages run to 25 MiB. - */ +// Pages reach 25 MiB; use Node's encoder. const pageBody = (html: string) => Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1"); @@ -151,11 +144,10 @@ const consoleMessageFromEvent = (method: string, params: unknown): ConsoleMessag return undefined; }; -// Resolves after web fonts load and two frames paint, so late layout lands in the capture. +// Wait for fonts and two paint frames to capture late layout. const SETTLE_EXPRESSION = "document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)))))"; -// The root's scroll height never drops below the viewport, so a short page -// reports its own box height instead. +// scrollHeight is at least viewport height; use the root box for shorter pages. const MEASURE_EXPRESSION = "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()"; @@ -174,10 +166,7 @@ interface PageEvents { omittedConsoleMessages: number; } -/** - * Starts the headless shell for the life of the scope, speaking CDP over - * `--remote-debugging-pipe` (fd 3 in, fd 4 out, NUL-delimited JSON). - */ +// CDP pipe uses fd 3 in, fd 4 out and NUL-delimited JSON. const launchBrowser = Effect.fnUntraced(function* (input: { readonly executable: string; readonly noSandbox: boolean; @@ -266,9 +255,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ), ).pipe(Effect.asVoid); - // Serves the page. Its body is shared bytes between two small JSON halves, - // queued together, so loading a page of up to 25 MiB at several widths never - // copies it on this side. + // Queue shared body bytes between JSON halves to avoid copies across capture widths. const fulfillPage = (sessionId: string, requestId: string, body: Uint8Array) => Queue.offerAll(outgoing, [ textEncoder.encode( @@ -308,8 +295,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { if (url === PAGE_URL && body !== undefined) { return fulfillPage(sessionId, paused.requestId, body); } - // A frame inside the page may show another site; Local Network Access - // still covers it. The main frame and T3's own origin serve nothing else. + // Subframes may show other sites covered by Local Network Access; the main frame stays on T3's page. const otherSiteFrame = paused.resourceType === "Document" && paused.frameId !== page.mainFrameId && @@ -337,8 +323,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { return Effect.void; }; - // The pipe closes when the browser exits. A startup abort, such as a missing - // sandbox, says why on stderr, which may still be draining, so give it a moment. + // Startup aborts close the pipe; let stderr drain to recover the reason. const disconnect = Effect.gen(function* () { yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore); const exit = yield* child.exitCode.pipe( @@ -367,7 +352,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); - // Decoded text chunks may split a message anywhere, and a screenshot reply spans many. + // Chunks may split messages; screenshot replies span many chunks. let partial: Array = []; yield* child.getOutputFd(4).pipe( Stream.decodeText(), @@ -413,7 +398,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); - const openPage = Effect.fnUntraced(function* (width: number) { + const openPage = Effect.fnUntraced(function* (width: number, appearance: ThemeAppearance) { const { targetId } = yield* send( "Target.createTarget", { url: "about:blank" }, @@ -436,6 +421,12 @@ const launchBrowser = Effect.fnUntraced(function* (input: { yield* send("Page.enable", {}, Ignored, sessionId); yield* send("Runtime.enable", {}, Ignored, sessionId); yield* send("Log.enable", {}, Ignored, sessionId); + yield* send( + "Emulation.setEmulatedMedia", + { features: [{ name: "prefers-color-scheme", value: appearance }] }, + Ignored, + sessionId, + ); // Previews have no use for WebRTC, whose ICE servers can make the browser // resolve names outside the proxy. Runs before page scripts in every frame. yield* send( @@ -447,8 +438,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { Ignored, sessionId, ); - // Pauses T3's own origin, to serve the page, and every document, to keep - // the main frame on it. + // Intercept T3's page and all document navigations to keep the main frame on it. yield* send( "Fetch.enable", { @@ -550,6 +540,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen readonly html: string; readonly width: number; readonly urlFragment: string; + readonly appearance: ThemeAppearance; }) { const path = yield* Path.Path; const directory = yield* scratchDirectory; @@ -558,7 +549,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen noSandbox: input.noSandbox, profileDirectory: path.join(directory, "profile"), }); - const page = yield* browser.openPage(input.width); + const page = yield* browser.openPage(input.width, input.appearance); const contentHeight = yield* page.load(pageBody(input.html), input.urlFragment); const capturedHeight = Math.max(1, Math.min(contentHeight, MAX_CAPTURE_HEIGHT)); const png = yield* page.screenshot(capturedHeight); @@ -576,10 +567,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen }), ); -/** - * Content heights of the page at each width, each from a fresh load, since - * pages often lay themselves out from the width once at load. - */ +/** Fresh load per width, since pages may lay out only at load. */ export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights")(function* (input: { readonly executable: string; readonly noSandbox: boolean; @@ -598,7 +586,7 @@ export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights") return yield* Effect.forEach( input.widths, (width) => - browser.openPage(width).pipe( + browser.openPage(width, "dark").pipe( Effect.flatMap((page) => page.load(body, input.urlFragment).pipe(Effect.ensuring(page.close)), ), diff --git a/apps/server/src/htmlRender/publicProxy.ts b/apps/server/src/htmlRender/publicProxy.ts index ce6b7796fe13..e73135e5a8f1 100644 --- a/apps/server/src/htmlRender/publicProxy.ts +++ b/apps/server/src/htmlRender/publicProxy.ts @@ -19,8 +19,7 @@ for (const [network, prefix] of [ ] as const) { LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv4"); } -// Also the IPv6 ranges that carry an IPv4 address a translator may route to -// without checking it: IPv4-compatible, local-use NAT64, and Teredo. +// Block IPv4-carrying IPv6 ranges that translators may route without checking. for (const [network, prefix] of [ ["::", 96], ["64:ff9b:1::", 48], @@ -52,11 +51,7 @@ const ipv6Groups = (address: string) => { ); }; -/** - * The IPv4 address a NAT64 or 6to4 address stands for, which a translator - * will route to, so it is checked as well. Public NAT64 targets stay - * reachable, as on IPv6-only networks. - */ +// Check the translated IPv4 too, while allowing public NAT64 targets on IPv6-only networks. const embeddedIPv4 = (address: string) => { const at = NAT64.check(address, "ipv6") ? 6 : SIX_TO_FOUR.check(address, "ipv6") ? 1 : -1; if (at === -1) return undefined; @@ -81,10 +76,7 @@ const isLocal = (address: string, family: number): boolean => { return embedded !== undefined && isLocal(embedded, 4); }; -/** - * The caller connects only to addresses checked here, so a name that later resolves elsewhere - * (DNS rebinding) changes nothing. - */ +// Connect only to checked addresses to prevent DNS rebinding. const publicAddresses = async (host: string) => { const addresses = NodeNet.isIP(host) ? [{ address: host, family: NodeNet.isIP(host) }] @@ -121,10 +113,7 @@ const readRequest = (data: Buffer) => { return { command: data[1], host, port: data.readUInt16BE(end - 2), rest: data.subarray(end) }; }; -/** - * Chrome's Local Network Access misses some requests, such as speculation-rules prefetches; - * nothing misses this. - */ +/** Covers requests Chrome's Local Network Access misses, including speculation prefetches. */ export const publicProxy = Effect.acquireRelease( Effect.callback< { readonly server: NodeNet.Server; readonly sockets: Set }, @@ -171,8 +160,7 @@ export const publicProxy = Effect.acquireRelease( client.off("data", holdEarly); if (addresses === undefined) return refuse(client, 2); client.pause(); - // Tries every checked address, IPv6 and IPv4 alike, so a host is - // still reached on a network whose IPv6 route is broken. + // Try all checked addresses so broken IPv6 routes do not block IPv4 access. const upstream = track( NodeNet.connect({ host: request.host, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 48c75a124035..daefb3b91350 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -148,9 +148,7 @@ const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (sta ServerHttp.layerStaticAndDevRoute, ServerHttp.layerHttpCompression, ).pipe(Layer.provideMerge(NodeHttpPlatform.layer)); - // HttpRouter.serve reuses services from the layers around it before the - // app's own, and NodeHttpServer.layerTest brings a real FileSystem, so the - // caller's FileSystem and static directory go between the two. + // HttpRouter.serve prefers outer services; inject the test FileSystem between it and NodeHttpServer.layerTest. const services = yield* Layer.build( HttpRouter.serve(layerApp, { disableListenLog: true }).pipe( Layer.provideMerge(ServerConfig.layer({ ...config, staticDir })), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8871a5df3451..d281144cd977 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -31,7 +31,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; -import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; +import { sourceControlMediaResponse } from "./assets/sourceControlMediaResponse.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, @@ -58,15 +58,12 @@ import { const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -// The sandbox gives the document an opaque origin: scripts run, but same-origin -// cookies, storage, and API calls are out of reach. No modals: agent HTML can -// open without a click (inline renders, and mobile loads it as the top document), -// and must not raise blocking dialogs. +// Opaque origin blocks cookies, storage and API access while allowing scripts. +// Agent HTML can open without a click, so disallow blocking modals. const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-downloads"; -// Types a browser may render as a document if a proxy strips the disposition -// header. Downloads of these fall back to octet-stream. +// Proxies may strip disposition; force document-capable downloads to octet-stream. const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; const isSafeDownloadMimeType = (mimeType: string): boolean => DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && @@ -196,9 +193,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? ""); if (isMedia) { - // Do not invite conditional range requests with validators that cannot establish - // byte-for-byte identity. Attachment media carries no `file`, and must not - // outlive the signed URL that granted it either. + // Without file identity, validators cannot guarantee byte equality; attachments must also expire with their URL. headers["Cache-Control"] = "private, no-store"; } let status = 200; @@ -259,8 +254,7 @@ export const layerBrowserApiCors = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; - // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be - // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. + // Credentialed dev requests need explicit Vite/Electron origins; packaged desktop uses the default wildcard. return HttpRouter.cors({ ...(devOrigin ? { @@ -333,8 +327,7 @@ class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecor readonly cause: unknown; }> {} -// Renderers export up to once a second while they have spans buffered, so -// tracing this proxy would add more server spans than it forwards. +// Tracing once-per-second renderer exports would create more spans than it forwards. export const layerOtlpTracesProxyRoute = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, @@ -391,9 +384,7 @@ export const layerOtlpTracesProxyRoute = HttpRouter.add( const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_PATH]); -// TracerDisabledWhen is one predicate for the whole server, and -// HttpRouter.serve builds its routes privately, so it is provided to the served -// layer, never merged into the routes. +// HttpRouter.serve builds routes privately; provide the server-wide TracerDisabledWhen to the served layer. const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { const queryIndex = request.url.indexOf("?"); const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex); @@ -432,10 +423,15 @@ export const layerAssetRoute = HttpRouter.add( headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" }, }); } - if (asset.kind === "github-media") { - return yield* githubMediaResponse(asset, request.headers).pipe( + if (asset.kind === "source-control-media") { + return yield* sourceControlMediaResponse(asset, request.headers).pipe( Effect.tapError((cause) => - Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }), + // The URL can carry a token in its query string, and the error keeps the request it + // failed on; the host and the failure kind are enough to diagnose. + Effect.logWarning("Failed to fetch source control media.", { + host: URL.canParse(asset.url) ? new URL(asset.url).host : undefined, + reason: cause.reason._tag, + }), ), Effect.orElseSucceed(() => HttpServerResponse.empty({ @@ -664,8 +660,7 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( const etag = headers.ETag; const lastModified = headers["Last-Modified"]; - // If-None-Match takes precedence over dates and uses weak comparison for - // GET/HEAD, including when compression changes the transferred bytes. + // If-None-Match precedes dates and uses weak GET/HEAD comparison even across compression. const ifNoneMatch = request.headers["if-none-match"]; const ifModifiedSince = request.headers["if-modified-since"]; const unchanged = diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index ae23fc37a08e..ed3c7e65c797 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -87,9 +87,7 @@ describe("guardHttpResponseWriteErrors", () => { }); server.on("upgrade", (_request, socket) => { - // Simulate the client vanishing while the auth rejection response is - // written to the upgrade socket: the write failure surfaces as an - // "error" event on a socket Node's http server no longer listens to. + // Node's HTTP server no longer listens for errors on an upgraded socket. socket.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); }); diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts index b243fba20a87..0f215ba797f8 100644 --- a/apps/server/src/httpResponseErrorGuard.ts +++ b/apps/server/src/httpResponseErrorGuard.ts @@ -3,11 +3,6 @@ import type * as NodeHttp from "node:http"; import type * as NodeStream from "node:stream"; -/** - * An "error" event without a listener escalates into an uncaught exception and - * terminates the whole server process, taking every other client and all - * in-flight provider work with it. - */ export function guardHttpResponseWriteErrors( server: T, onError?: (error: unknown) => void, diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index b180dda51a09..338bbd62ce59 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -45,10 +45,7 @@ function isBase64Whitespace(code: number): boolean { return code === 0x0d || code === 0x0a || code === 0x20; } -// Data URLs carry the full image payload, so this parser must never run a -// regex across the payload: V8's regex engine borrows the JS call stack, and -// matching a multi-megabyte string from a deep call stack (e.g. inside fiber -// execution) throws "Maximum call stack size exceeded". +// V8 regexes use the JS stack; scanning image payloads can overflow a deep fiber stack. export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { @@ -100,8 +97,7 @@ export function parseBase64DataUrl( if (base64.length === 0 || base64.length % 4 !== 0) return null; const firstPad = base64.indexOf("="); if (firstPad !== -1) { - // '=' is only valid as one or two trailing padding characters; Node's - // decoder would otherwise silently truncate at the first '='. + // Node silently truncates at the first =; allow only one or two trailing padding characters. if (base64.length - firstPad > 2) return null; for (let index = firstPad; index < base64.length; index += 1) { if (base64.charCodeAt(index) !== 0x3d) return null; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 7fbfdbe9e651..bf87ee2eabc0 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -548,8 +548,7 @@ const make = Effect.gen(function* () { } } - // Keep only defaults that fit, before persisted rules so existing - // custom shortcuts retain priority when their conditions overlap. + // Put defaults before persisted rules so custom shortcuts win overlapping conditions. const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); const defaultsToAppend = missingDefaults.slice(0, availableSlots); const skippedDefaults = missingDefaults.slice(availableSlots); @@ -594,8 +593,7 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // Editors emit multiple events per save (truncate, write, rename) and - // `fs.watch` can fire before the content has been flushed to disk. + // Saves emit multiple events; fs.watch may fire before content is flushed. const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( Stream.filter((event) => { return ( diff --git a/apps/server/src/launcherWatch.ts b/apps/server/src/launcherWatch.ts index d88702802474..ca109422c95a 100644 --- a/apps/server/src/launcherWatch.ts +++ b/apps/server/src/launcherWatch.ts @@ -4,22 +4,13 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_POLL_INTERVAL = Duration.seconds(2); -// Sampled when this module loads, before config resolution and the bootstrap -// read, so a launcher that dies during startup is still noticed. +// Capture before config and bootstrap reads to detect launcher loss during startup. const startupParentPid = process.ppid; /** - * Completes when the process that launched this backend is gone. - * - * A desktop-launched backend reads its bootstrap envelope once and never looks - * at its launcher again. When the launcher dies without a graceful stop (a WSL - * relay killed during an app update, for instance), the backend would keep - * serving and holding its ownership lock while no desktop can reach it, and - * every replacement launch is refused. An orphan is adopted by init or, on - * Linux, by a subreaper, so any change of parent counts rather than only a - * move to pid 1. A backend that already starts under init has no launcher to - * lose, and Windows keeps the original parent id after the parent exits, so - * neither is watched. + * Any parent change signals launcher loss, including adoption by a Linux subreaper; + * otherwise an orphan backend keeps its ownership lock and blocks replacement launches. + * Skip init-launched backends and Windows, which retains the exited parent's id. */ export const awaitLauncherLoss = Effect.fn("launcherWatch.awaitLauncherLoss")(function* (options?: { readonly launcherPid?: number; diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index ea9a4c93b00f..2e7c22ce0040 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -1,5 +1,4 @@ -// The bridge relays opaque JSON-RPC lines verbatim; schema-decoding foreign -// payloads here would reject traffic it must pass through untouched. +// Relay JSON-RPC verbatim; schema decoding would reject foreign payloads. // @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off import * as NodeReadline from "node:readline"; @@ -10,10 +9,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; -/** - * ACP agents must support stdio MCP servers, while optional http/sse support is - * unevenly implemented. - */ +/** ACP mandates stdio MCP support; HTTP/SSE support varies. */ export class AcpMcpBridgeError extends Error { readonly _tag = "AcpMcpBridgeError"; @@ -253,10 +249,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. ), ); - // Only session establishment is ordered; client responses to - // server-initiated SSE requests must bypass the barrier or they would - // deadlock the still-open HTTP exchange, and normal MCP calls stay - // concurrent after initialization. + // Order only session establishment; SSE responses must bypass it to avoid deadlock. Calls remain concurrent after initialization. let sessionBarrier: Effect.Effect = Effect.void; let sessionBarrierPending = false; @@ -327,10 +320,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. }); } -/** - * Kept free of heavy imports: these commands run on the ACP first-message - * critical path. - */ +/** Avoid heavy imports on the ACP first-message path. */ export async function runAcpMcpCliFastPath( command: "acp-mcp-bridge" | "acp-mcp-call", args: ReadonlyArray, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 85ccc3ab926e..286dc409f743 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -132,7 +132,6 @@ const callTool = (name: string, args: Record) => const callSnapshot = (args: Record) => callTool("preview_snapshot", args); -// Real tool definitions with handlers that record what they decoded. const layerRecordingTools = (calls: Array) => { const RecordingToolkit = Toolkit.make( OrchestratorToolkit.tools.delegate_task, @@ -365,7 +364,6 @@ it.effect("decodes null optional arguments the way it decodes omitted ones", () yield* callTool("t3_thread_read", { ...thread, afterPosition: null, view: null }); yield* callTool("delegate_task", { task: "Inspect", target: {} }); yield* callTool("delegate_task", { task: "Inspect", title: null, target: { model: null } }); - // A null the schema declares keeps its meaning: here it clears the project icon. yield* callTool("t3_project_update", { projectId: "project-1", projectIcon: null }); expect(calls).toHaveLength(5); @@ -422,6 +420,17 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); +it("routes same-child follow-ups to t3_thread_send and independent reviews to delegate_task", () => { + const description = OrchestratorToolkit.tools.delegate_task.description ?? ""; + expect(description).toContain( + "For independent T3 delegated review rounds, call delegate_task again", + ); + expect(description).toContain( + "For a follow-up that needs the same child's conversation, use t3_thread_send on its childThreadId.", + ); + expect(description).not.toContain("not the target for starting another"); +}); + it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => @@ -533,7 +542,6 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => expect(parsed.consoleEntries[0]?.text).toBe("entry 60"); expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree"); expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries"); - // Claude Code shows the model structuredContent instead of the text, so it is bounded too. expect(snapshot.structuredContent).toEqual({ ...parsed, omitted: expect.arrayContaining(["60 older console entries"]), diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 2e0d16c535c8..5a4e62e3b241 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -78,11 +78,7 @@ const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`), ); -/** - * Agents T3 Code launched carry a registry token and must never be sent into an - * OAuth flow when it dies: they cannot open a browser, and a sign-in would mint - * a credential that outlives their session. - */ +/** Launched agents cannot open OAuth browsers; OAuth would mint credentials outliving their session. */ const unauthorized = (input: { readonly request: HttpServerRequest.HttpServerRequest; readonly presentedToken: boolean; @@ -189,13 +185,7 @@ const layerMcpAuthMiddleware = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; -/** - * Claude Code moves an MCP result above its output limit to a file and hands - * the agent a notice instead, so a snapshot that carries the full accessibility - * tree and page text loses its locators too. Claude Code also shows the model - * `structuredContent` in place of the text blocks when a result has both, so - * both carry the same bounded snapshot. - */ +/** Claude Code spills oversized results to files, losing locators; bound both text and its preferred structuredContent. */ export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; @@ -280,8 +270,7 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { actionTimeline: tail(metadata.actionTimeline, "action timeline entries"), }; - // Per-field caps do not sum below the ceiling: three log arrays of 40 capped - // entries alone can pass 60 KB, and the caps count characters, not bytes. + // Per-field character caps can still exceed the total byte ceiling. const shedOrder = [ "actionTimeline", "networkEntries", @@ -330,7 +319,6 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { } else { const keep = Math.floor(lists[key].length / 2); dropped[key] += lists[key].length - keep; - // slice(-0) keeps everything, so spell out the empty case. lists[key] = keep === 0 ? [] @@ -400,16 +388,10 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( return screenshotPath; }); -/** - * Tool input schemas are advertised through each schema's JSON codec, which - * offers `null` for every `Schema.optional` field, but Toolkit decodes with the - * schema itself and rejects that `null`. Decoding through the same codec makes - * a tool accept what it advertises. - */ +/** Decode with the advertised JSON codec, which permits null for optional fields that the raw schema rejects. */ const decodeAsAdvertised = >( toolkit: Toolkit.Toolkit, ): Toolkit.Toolkit => - // The clones keep each tool's id, name and decoded parameter type, so handlers still match. Toolkit.make( ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.toCodecJson(tool.parametersSchema)), @@ -431,8 +413,7 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { typeof firstFailure._tag === "string" ? firstFailure._tag : "PreviewSnapshotError"; - // Preview errors build their message on the server, never from page output, - // and it tells the agent what to do next, such as falling back to a shell browser. + // Preview errors contain server-authored recovery guidance, never page output. const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined; const result = new McpSchema.CallToolResult({ isError: true, @@ -508,8 +489,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); const screenshotPath = payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; - // Images stay out of tool history unless asked for: providers replay them on every - // later request, and some reject inline images outright. + // Providers replay images on later requests, and some reject them; include images only when requested. const includeImage = payload?.includeImage === true; if (screenshotPath !== undefined && !includeImage) { const saved = { @@ -581,11 +561,7 @@ interface ImageToolResult { readonly [key: string]: unknown; } -/** - * Failures surface only their tag unless the tool describes them: the remote - * message may carry renderer or device output the agent should not see, and the - * tag is what it can act on. - */ +/** Expose only undescribed failures' tags; remote messages may contain renderer or device output. */ const imageToolFailure = (toolName: string, operation: string, failureText: string | ((error: E) => string)) => (cause: Cause.Cause) => { @@ -815,15 +791,7 @@ const handoffTargetIds = ( return typeof threadId === "string" ? [threadId] : []; }; -/** - * Runs a tool registration against a server that records an anonymous - * `mcp.tool.invoked` event for every handoff call and every failed call of - * other tools: the tool, the calling agent's provider, the outcome with its - * failure code, and the duration. A handoff tool also - * reports the caller's model, origin, and delegation depth, the settings the - * agent chose, and the provider, model, and modes of each thread that received - * the work. Ids, prompts, and free text are never recorded. - */ +/** Tool analytics must never include IDs, prompts or free text. */ const withToolAnalytics = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (registration: Effect.Effect) { diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index f830da3d4a03..c479b03df73d 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -32,18 +32,11 @@ export interface McpClientCaller { readonly access: AuthMcpClientAccess; } -/** - * A read-only client never reaches a write (`McpToolAccess` refuses it first), - * so it maps to the lowest mode rather than to nothing. - */ +/** Read-only clients map to the lowest mode because McpToolAccess blocks writes first. */ export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => client === undefined || client.access === "read-only" ? "approval-required" : client.access; -/** - * A thread caller's omitted target falls back to its own thread; a client - * caller has no own thread, so tools that act as the caller (delegate_task, - * preview, worktree handoff) need `thread`. - */ +/** Thread callers default to their own thread; client callers need thread for tools that act as the caller. */ export interface McpInvocationScope { readonly environmentId: EnvironmentId; readonly capabilities: ReadonlySet; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 04c33a1509e9..2fc9879d7a34 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -15,11 +15,7 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; - /** - * When false, the credential is minted without the "preview" capability so - * the user's choice to withhold agent browser access holds everywhere the - * token is honored (#7083). Defaults to full access. - */ + /** False omits the preview capability wherever the token is honored. Defaults to full access. */ readonly browserToolsAvailable?: boolean; readonly capabilities?: ReadonlySet; } diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 7bd0d66fa040..ecfb8a9993b3 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -216,7 +216,6 @@ const supervised = threadCaller(supervisedThreadId); const plan = threadCaller(planThreadId); const fullAccess = threadCaller(fullAccessThreadId); const ended = threadCaller(endedThreadId); -// Its turn ended, but a background subagent it started still runs. const background = threadCaller(backgroundThreadId); const oldProviderBackground = threadCaller(oldProviderThreadId); const archivedBackground = threadCaller(archivedThreadId); diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 5cc8dc0c38b8..9d99fb7da89e 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -27,10 +27,7 @@ import { unavailable, } from "./threadAccess.ts"; -/** - * Their private constructors only stop the type checker; this also stops - * `Reflect.construct` and friends at runtime. - */ +/** Private constructors only enforce types; the token also blocks runtime construction. */ const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); const refuseOutsideConstruction = (token: symbol) => { @@ -55,11 +52,7 @@ let handlersLayer: , EX, RX>( layer: Layer.Layer, EX, RX>, ) => HandlersLayer; -/** - * Effect's own registration functions accept any handler, so the - * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside - * McpHttpServer. - */ +/** t3code/no-raw-mcp-registration confines Effect's unchecked registration API to McpHttpServer. */ export class Declaration { readonly #handle: Handler; private constructor(token: typeof builtHere, handle: Handler) { @@ -117,10 +110,7 @@ export const actsAsCaller = (handle: (params: P) => Effect.Effect(handle: (params: P) => Effect.Effect) => declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); -/** - * Unlike `writes` it does not need thread control; the handler checks its - * capability, as the capability toolkits do. - */ +/** Requires handler capability checks, not thread control. */ export const writesWithCapability = (handle: (params: P) => Effect.Effect) => declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params)))); @@ -140,9 +130,7 @@ export const writesThreads = ( yield* assertTargetWithinLimits(caller.limits, target); } } - // The target's user can raise its modes after the check above; the - // orchestrator checks again under the thread's lock and records its - // refusal here, since handlers wrap dispatch errors their own way. + // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. const refused = yield* Ref.make(undefined); return yield* handle(params).pipe( Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), @@ -196,10 +184,7 @@ export const startsThreads = ( const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; -/** - * `check` re-checks the caller wherever the handler waits before writing, such - * as for a lock, since the caller's modes can change meanwhile. - */ +/** Recheck after waits before writing; caller modes may have changed. */ export const writesEnvironment = ( handle: ( params: P, diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 9d2f10bc6c02..d7f9746f76ea 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -468,7 +468,6 @@ it("readThread and sendToThread reach threads in other projects", async () => { .pipe(Effect.flip); expect(staleDelete.code).toBe("parent_not_active"); - // A background item keeps only its owning instance live, even after a switch. const backgroundTasks = [ { taskId: "background-subagent", @@ -512,7 +511,6 @@ it("readThread and sendToThread reach threads in other projects", async () => { .pipe(Effect.flip); expect(mismatched.code).toBe("parent_not_active"); - // A fresh active run wins over the earlier inactive projection and empty roster. parentShell = liveThreadShell(parentThreadId); const restarted = yield* service.sendToThread(makeScope(), { threadId: foreignThreadId, diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index bba4e816caca..cfb6408b79c0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -398,7 +398,7 @@ describe("OrchestratorMcpService", () => { }, ], }) as unknown as OrchestrationV2ThreadProjection; - // A finished run whose binding to the follow-up was lost (#19002). + // A finished run whose binding to the follow-up was lost. const childProjection = { thread: { id: childThreadId }, runs: diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8f39c2e452a6..f33956104400 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -70,7 +70,7 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -174,9 +174,7 @@ export interface OrchestratorMcpServiceShape { scope: McpInvocationScope, input: OrchestratorMcpDeleteScheduledTaskInput, ) => Effect.Effect; - /** - * The value never reaches the agent: the result is a status. - */ + /** Only status reaches the agent, never this value. */ readonly requestSecret: ( scope: McpInvocationScope, input: OrchestratorMcpRequestSecretInput, @@ -214,13 +212,7 @@ function failure(code: OrchestratorMcpFailure["code"], message: string): Orchest return new OrchestratorMcpFailure({ code, message }); } -/** - * A caller's thread is live while it is not archived, runs on the caller's - * provider instance, and has an active run or a background subagent that - * instance started. A background subagent outlives its run and calls tools - * with its thread's credential, which is revoked when the provider process - * goes away. - */ +/** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ export const isLiveCallerShell = ( shell: Pick< OrchestrationV2ThreadShell, @@ -377,7 +369,6 @@ function providerConstraints( constraints.push("No V2 provider adapter is registered."); } if (provider === undefined) return constraints; - // Driver availability comes from the registry, even when health probes are skipped. if (!isProviderAvailable(provider)) { constraints.push(provider.unavailableReason ?? "Provider driver is unavailable."); } @@ -396,11 +387,7 @@ function providerConstraints( return constraints; } -/** - * Models without descriptors skip the descriptor checks (mirroring how model slugs are only - * validated when the provider advertises models), but duplicate ids always fail: downstream - * consumers disagree on whether the first or last value of a duplicated id wins. - */ +/** Skip checks without descriptors; reject duplicate IDs because consumers disagree on precedence. */ function invalidOptionSelections( selections: ReadonlyArray, descriptors: ReadonlyArray | undefined, @@ -848,7 +835,6 @@ function threadSnooze( }; } -/** The same links `list_thread_pull_requests` reports, without the snapshot details. */ function threadPullRequests( links: OrchestrationV2ThreadShell["pullRequests"], ): OrchestratorMcpThreadPullRequest[] { @@ -1228,10 +1214,7 @@ const make = Effect.gen(function* () { return { ...caller, target } as const; }); - /** - * A thread caller writes to another thread only while its own run is live, - * so a credential that outlived its session cannot reach across threads. - */ + /** Cross-thread writes require a live caller run; stale session credentials cannot authorize them. */ const assertLiveCallerForOtherThread = ( scope: McpInvocationScope, parent: Pick | undefined, @@ -1307,11 +1290,7 @@ const make = Effect.gen(function* () { const loadOrchestrationCapableInstanceIds = () => providerAdapters.list().pipe(Effect.map((instanceIds) => new Set(instanceIds))); - /** - * Provider snapshots only re-probe while a client is in the foreground, so an - * unattended agent can see a provider as unavailable after it was fixed. - * Re-probe the requested instance once before refusing it. - */ + /** Background agents miss foreground-only health probes; re-probe once before refusing. */ const assertTargetEnabledForProject = ( parent: Pick, commandId: CommandId, @@ -1348,13 +1327,7 @@ const make = Effect.gen(function* () { readonly parent: Pick; readonly target: OrchestratorMcpTarget | undefined; readonly providers: ReadonlyArray; - /** - * Set only when the command that will dispatch this target already has a receipt: that - * dispatch can only replay the recorded result, never start anything new, so filtering - * candidates by the project's current settings would just refuse a retry that does no new - * work. Never set this for a resolution whose dispatch uses a different command id than the - * one checked for a receipt. - */ + /** Receipt replays bypass current settings because they start no work. Dispatch must reuse the checked command ID. */ readonly skipProjectEnablementFilter?: boolean; }): Effect.fn.Return { const requestedInstanceId = input.target?.providerInstanceId; @@ -1698,8 +1671,7 @@ const make = Effect.gen(function* () { "orchestration_error", `Unable to watch delegated task ${taskId}: ${errorMessage(error)}`, ); - // Sequences are global, so one cursor taken before the first read - // replays anything either thread records after it. + // One global cursor before both reads captures later events from either thread. const afterSequence = yield* threadManagement .getThreadEventSequence(scope.thread.threadId) .pipe(Effect.mapError(streamError)); @@ -1907,9 +1879,7 @@ const make = Effect.gen(function* () { : input.bindToCurrentThread && parent !== undefined ? parent.thread.id : null; - // Rebinding changes where runs execute, so the workspace strategy must - // follow: unbinding a root-strategy task would otherwise run loose - // prompts in the shared project checkout. + // Update workspace strategy on rebinding so unbound root tasks cannot run in the shared checkout. const workspaceStrategy = input.bindToCurrentThread === undefined ? existing.workspaceStrategy @@ -2460,7 +2430,7 @@ const make = Effect.gen(function* () { ), ), ); - // A follow-up that lost its child run (#19002) stays running on the parent, while its + // A follow-up that lost its child run stays running on the parent, while its // status here comes from the child's last run. Stop on the parent cancels that task. const cancelOrphan = Effect.gen(function* () { if (parentTask?.runId == null || !isOrchestrationV2WorkActive(parentTask.status)) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 7ae8bfbefead..f9d618f0520e 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1027,7 +1027,6 @@ it.effect("keeps each thread's current tab when threads share a provider session ["thread-1", "tab-thread-1"], ["thread-2", "tab-thread-2"], ]); - // Each thread's agent owns its own tabs and tab budget. expect(routed[0]!.agentSessionId).not.toBe(routed[1]!.agentSessionId); }), ), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index c1ae21dfd3b4..2aced7dc3684 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -93,11 +93,7 @@ interface PendingRequest { readonly context: PreviewAutomationRequestErrorContext; } -/** - * It lives exactly as long as the connection it names: `connectionId`/`queue` - * identity is what makes a lease valid, so a disconnected or replaced host is - * dropped on the next lookup. - */ +/** A lease belongs to this exact connectionId/queue; replacing the host invalidates it. */ interface HostAssignment { readonly clientId: ClientConnection["clientId"]; readonly connectionId: ClientConnection["connectionId"]; @@ -166,11 +162,7 @@ const selectorDiagnosticsFromInput = ( return {}; }; -/** - * One agent: a provider session on one thread. Codex and OpenCode run every - * thread of a provider instance in one session, so the session alone would - * share one current tab, tab budget, and tab ownership across chats. - */ +/** Codex/OpenCode share sessions across threads; include thread id to isolate tabs and ownership. */ const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string => `${scope.environmentId}\u0000${scope.thread.providerSessionId}\u0000${scope.thread.threadId}`; @@ -205,6 +197,14 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { } } +const readRemoteTimeoutMs = (detail: unknown): number | undefined => { + if (typeof detail !== "object" || detail === null || !("timeoutMs" in detail)) return undefined; + const timeoutMs = detail.timeoutMs; + return typeof timeoutMs === "number" && Number.isInteger(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : undefined; +}; + const MAX_REASON_CHARS = 500; const classifyResponseError = ( @@ -253,11 +253,16 @@ const classifyResponseError = ( ...context, ...remoteDiagnostics, }); - case "PreviewAutomationTimeoutError": + case "PreviewAutomationTimeoutError": { + const remoteTimeoutMs = readRemoteTimeoutMs(error.detail); return new PreviewAutomationTimeoutError({ ...context, ...remoteDiagnostics, + ...(remoteTimeoutMs === undefined + ? {} + : { timeoutMs: Math.min(context.timeoutMs, remoteTimeoutMs) }), }); + } case "PreviewAutomationControlInterruptedError": { const reason = decodeControlReason(error.detail); return new PreviewAutomationControlInterruptedError({ @@ -357,8 +362,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { completeStream = false, ) { if (completeStream) { - // Discard this generation's commands and complete the RPC stream so a - // responsive desktop can re-register after a timeout eviction. + // Close the evicted generation's RPC stream so a responsive desktop can re-register. yield* Queue.clear(queue); yield* Queue.end(queue); } else { @@ -515,9 +519,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const assigned = assignments.get(assignmentKey); const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined; const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId; - // Keep one provider session on one physical desktop runtime so a - // multi-step browser interaction cannot jump between independent Electron - // cookie/DOM state. + // Pin interactions to one desktop runtime so cookie/DOM state cannot jump between hosts. const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => host.liveTabs.some( (tab) => @@ -550,8 +552,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { if (!hasLiveAssignment) assignments.delete(assignmentKey); return [undefined, { ...current, assignments }] as const; } - // The environment host may install Chromium on its first open (up to - // ten minutes). Keep that request alive without replaying its effects. + // First open may install Chromium for ten minutes; keep it alive without replaying effects. const timeoutMs = input.timeoutMs ?? (input.operation === "open" && connection.preferred ? 660_000 : 15_000); const canReuseAssignedTab = @@ -612,8 +613,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { const offered = yield* SynchronizedRef.modifyEffect(state, (current) => { - // A route can outlive its generation while another request evicts it. - // Serialize the live-generation check and offer with queue closure. + // Serialize generation checks and offers with queue closure; routes can outlive eviction. if ( current.clients.get(connection.clientId)?.queue !== connection.queue || !current.pending.has(requestId) @@ -642,18 +642,14 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { } return yield* new PreviewAutomationRequestQueueClosedError(requestContext); } - // The browser starts its operation timer after delivery. Allow its timeout - // response to arrive before treating the entire host as unresponsive. + // The browser timer starts on delivery; allow its timeout response before evicting the host. const responseTimeoutMs = input.updateCurrentTab === false ? timeoutMs : timeoutMs + HOST_RESPONSE_GRACE_MS; const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(responseTimeoutMs)); return yield* Option.match(result, { onNone: () => Effect.gen(function* () { - // Do not replay actions: the client may have applied them before - // becoming unreachable. A background metadata read has a short budget - // and changes nothing, so a slow one must not cut the host off from the - // agent's next call. + // Never replay possibly applied actions; slow metadata reads must not evict the host. if (input.updateCurrentTab !== false) { yield* disconnect(connection.clientId, connection.queue, true); } diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 9c36a26045dc..504ae749a78a 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -50,9 +50,6 @@ const driver = ProviderDriverKind.make("claudeAgent"); const instanceId = ProviderInstanceId.make("claudeAgent"); const modelSelection = { instanceId, model: "claude-test" }; -// A delegated Claude child ends its turn while a background native subagent it -// started keeps running (#17154). - it.effect("task_cancel ends a delegated child's background native subagent", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index e05200fd8eda..421d728461eb 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -203,8 +203,7 @@ const make = Effect.gen(function* () { const metadataEvent = dispatched.storedEvents.find( (stored) => stored.event.type === "thread.metadata-updated", ); - // A retried commandId replays the stored events of the first dispatch; - // compaction may have deleted them by then, so answer from the projection. + // Retries may find stored events compacted away; answer from the projection. const thread = metadataEvent?.event.type === "thread.metadata-updated" ? metadataEvent.event.payload diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 3902068894d6..c6a00aabcb0c 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -348,9 +348,7 @@ const expectTypedFailure = (exit: Exit.Exit, expected: object) expect(reason.error).toMatchObject(expected); }; -// Resolves the service once from the harness layer; used by tests that must -// make several calls against the SAME instance (the in-flight guard lives in -// the layer's closure, so a fresh layer per call would never see it). +// Resolve once so calls share the in-flight guard. const resolveService = (harness: ReturnType) => Effect.gen(function* () { return yield* WorktreeMcpService.WorktreeMcpService; @@ -371,7 +369,6 @@ const runStatus = (harness: ReturnType) => return yield* service.status(harness.scope); }).pipe(Effect.provide(harness.layer)); -// Decodes tool arguments the way the MCP tool does before calling the service. const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); describe("t3_worktree_handoff", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 461b7044ba87..8f02ee154642 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -63,9 +63,7 @@ const make = Effect.gen(function* () { const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - // Serializes handoffs per thread: two concurrent calls could otherwise both - // pass the worktreePath === null check and each create a worktree, leaving - // one untracked on disk. + // Serialize handoffs per thread to avoid creating two worktrees and orphaning one. const handoffThreadsInFlight = new Set(); const requireThreadScope = (scope: McpInvocationScope) => @@ -225,14 +223,7 @@ const make = Effect.gen(function* () { const ids = yield* handoffIds(scope); - // uninterruptibleMask: only the potentially slow worktree creation itself - // stays interruptible (restore). From the moment it succeeds, through the - // binding, continuation queue, setup script launch, and result - // construction, there is no interruptible gap: a client cancel can - // therefore neither orphan the created worktree before the rollback is - // armed, nor skip setting up a worktree the thread was just bound to - // (once the binding commits, the scheduled session detach can sever this - // request's connection and interrupt the fiber). + // Mask interruption after worktree creation through setup; binding can detach and interrupt the caller. return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const worktree = yield* restore( @@ -273,10 +264,7 @@ const make = Effect.gen(function* () { ).pipe(Effect.ignoreCause({ log: true })); const recheckAndBind = Effect.gen(function* () { - // The projection was read before the potentially slow git work - // above; a concurrent binding (for example from the UI) could have - // attached the thread in the meantime. Re-check before committing so - // the race cannot leave a second, untracked worktree. + // A concurrent UI binding may have landed during git work; do not orphan a second worktree. const recheck = yield* loadThread(scope); if (recheck.thread.worktreePath !== null) { return yield* alreadyInWorktree(recheck.thread.worktreePath); @@ -309,18 +297,13 @@ const make = Effect.gen(function* () { ), ); }).pipe( - // Interrupt-only causes skip the removal: the binding may have - // committed, and force-deleting a worktree the thread now points at - // would be worse than leaking one. + // Do not remove on interruption alone; the thread binding may already have committed. Effect.onError((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree, ), ); - // Queue the continuation right after the binding commits: the detach - // that the metadata update schedules will terminate the calling - // session, and a durably queued message is what guarantees the thread - // re-launches inside the worktree. + // Queue continuation immediately after binding; detachment terminates the caller, so relaunch needs a durable message. const queueContinuation: Effect.Effect = Effect.suspend(() => input.continuationPrompt === undefined @@ -401,11 +384,7 @@ const make = Effect.gen(function* () { function* (callerScope, input) { yield* requireCapability(callerScope); const scope = yield* requireThreadScope(callerScope); - // uninterruptibleMask: the guard acquisition and the registration of the - // releasing finalizer happen with no interruptible gap in between. An - // interrupt landing between a bare add() and the start of an ensured - // effect would otherwise leak the guard entry and block every future - // handoff for this thread until restart. + // Acquire and register cleanup atomically so cancellation cannot strand the guard until restart. return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { if (handoffThreadsInFlight.has(scope.thread.threadId)) { diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index ba8dc4c3e576..9f3a8a57c05b 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -102,12 +102,7 @@ export const assertTargetWithinLimits = ( Effect.asVoid, ); -/** - * A thread caller acts only while it owns a live run, or a background - * subagent that run started, of a thread that is not archived, so a provider - * token that outlived its session cannot act. An OAuth client has no run; its - * session and ceiling are its authority. - */ +/** Thread callers need a live run or its background subagent in an unarchived thread. OAuth clients rely on their session and ceiling. */ export function assertLiveCaller({ caller, scope }: Caller) { if (caller === undefined) return Effect.void; return OrchestrationMcp.isLiveCallerShell(caller, scope.thread?.providerInstanceId) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index e1a6428eba8d..4c7f168f6012 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -34,10 +34,7 @@ export function resolveAttachmentReferences( }); } -/** - * The sweep counts its 24 hours from when the upload finishes, which can be up - * to the upload URL's lifetime after issue. - */ +/** The 24-hour sweep starts at upload completion, possibly one upload-URL lifetime after issue. */ const UPLOAD_OWNER_TTL_MS = 24 * 60 * 60 * 1000 + ATTACHMENT_UPLOAD_URL_TTL_MS; const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( diff --git a/apps/server/src/mcp/toolkits/attachment/input.ts b/apps/server/src/mcp/toolkits/attachment/input.ts index f29d00d1f9f9..1f37edbbf6d4 100644 --- a/apps/server/src/mcp/toolkits/attachment/input.ts +++ b/apps/server/src/mcp/toolkits/attachment/input.ts @@ -2,8 +2,7 @@ import { ChatFileAttachment, ChatImageAttachment } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -// Screenshot provenance is app-owned. Existing attachments recover it from stored -// metadata; exposing its recursive accessibility tree breaks model tool schemas. +// Keep screenshot provenance in app metadata; its recursive tree breaks model tool schemas. export const McpAttachmentInput = Schema.Union([ ChatImageAttachment.mapFields(Struct.omit(["source"])), ChatFileAttachment, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 1e4abf2062e4..76d8131e27bb 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -59,8 +59,6 @@ const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), ); -// Effect returns a declared tool failure as `isError` with its encoded payload -// as JSON text, never as `structuredContent`. const declaredFailure = (result: McpSchema.CallToolResult) => { const text = result.content[0]; return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 31d8c41dccf4..36a91b844ade 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -30,8 +30,7 @@ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure] const DeviceListTool = Tool.make("device_list", { description: "List iOS Simulators and Android Emulators on this environment's device hosts, which platforms each host can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.", - // An empty struct serializes as `anyOf [object, array]`, which some - // providers reject and then drop every tool on the server with it. + // Empty Struct emits object|array, which can make providers reject every server tool. parameters: Schema.Struct({ hostId: Schema.optional( Schema.String.annotate({ description: "Limit to one device host. Defaults to all hosts." }), diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index edd7146f81d0..1a402ca0bb4e 100644 --- a/apps/server/src/mcp/toolkits/html/handlers.ts +++ b/apps/server/src/mcp/toolkits/html/handlers.ts @@ -20,8 +20,7 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) = }); const handlers = { - // The headless browser runs on the host and can open local files, so only - // agents T3 launched, which already work on this machine, get it. + // Local-file access limits this host browser to T3-launched agents. html_preview: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const htmlRender = yield* HtmlRender.HtmlRender; diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index 24859c576288..f061923993b8 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -69,8 +69,7 @@ export const HtmlPreviewTool = Tool.make("html_preview", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, true); -// Read-only in the MCP sense: it shows a page in the caller's own thread and -// touches no workspace, so plan mode and read-only sandboxes can use it. +// This affects only the caller's thread, not its workspace, so read-only sandboxes and plan mode may use it. const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width. On mobile, a height below the page's contentHeight caps the frame there, and the rest scrolls inside it; web and desktop fit the whole page, up to ${HTML_RENDER_MAX_FIT_HEIGHT}px. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`, parameters: Schema.Struct({ diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 0df604a85011..a6f9a1302c67 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -57,6 +57,27 @@ const handlers = { ); }), ), + t3_background_wait: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const { scope, threads } = yield* readCaller(); + const caller = yield* McpInvocationContext.requireThreadScope(scope, "t3_background_wait"); + return yield* threads + .awaitBackgroundTasks({ + threadId: caller.thread.threadId, + taskIds: input.taskIds, + awaited: input.waiting ?? true, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorMcpFailure({ + code: cause.reason === "unsupported" ? "invalid_request" : "orchestration_error", + message: cause.message, + }), + ), + ); + }), + ), schedule_task: McpToolAccess.startsThreads( () => ({}), (input) => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 567cb9272732..0aa0b430b7c7 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -31,6 +31,7 @@ import { OrchestratorMcpThreadWaitResult, ThreadMetadataMcpUpdateInput, ThreadMetadataMcpUpdateResult, + TrimmedNonEmptyString, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; @@ -67,7 +68,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { const DelegateTaskTool = Tool.make("delegate_task", { description: - "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Use delegate_task when model options are requested or the child keeps the parent's provider instance and model with nonempty inheritedOptions from orchestrator_capabilities, even for native-supported models. Native subagents cannot reliably apply T3 model options. Set target.options using advertised IDs: Claude {effort: 'max', fastMode: true}; Codex {reasoningEffort: 'max', serviceTier: 'priority'}. Check providerInstanceId, model, and options in the result. Provider/model and modes inherit unless overridden. Options inherit only for the same instance and model; explicit entries override individual values and [] or {} clears them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Leave runtimeMode unset unless the user asks for a supervised child: approval-required and auto-accept-edits usually make the child wait for a person at each action that needs approval. Prefer mode='async' for long work; mode='wait' blocks until completion, a blocking question, a pending approval, or timeout. If hasPendingQuestion, pass threadId=childThreadId to t3_pending_request_list, t3_pending_request_read (with requestId) and t3_pending_request_respond, or ask the user; the task's completion still wakes this thread. If pendingApproval is set, read the fresh full request in childThreadId before responding: the record can outlive its provider callback. It grants no approval authority; decisions remain with the child's existing approval holder, and pending request tools answer questions only. The child keeps running and its completion still wakes this thread. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For independent T3 delegated review rounds, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. For a follow-up that needs the same child's conversation, use t3_thread_send on its childThreadId. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Use delegate_task when model options are requested or the child keeps the parent's provider instance and model with nonempty inheritedOptions from orchestrator_capabilities, even for native-supported models. Native subagents cannot reliably apply T3 model options. Set target.options using advertised IDs: Claude {effort: 'max', fastMode: true}; Codex {reasoningEffort: 'max', serviceTier: 'priority'}. Check providerInstanceId, model, and options in the result. Provider/model and modes inherit unless overridden. Options inherit only for the same instance and model; explicit entries override individual values and [] or {} clears them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Leave runtimeMode unset unless the user asks for a supervised child: approval-required and auto-accept-edits usually make the child wait for a person at each action that needs approval. Prefer mode='async' for long work; mode='wait' blocks until completion, a blocking question, a pending approval, or timeout. If hasPendingQuestion, pass threadId=childThreadId to t3_pending_request_list, t3_pending_request_read (with requestId) and t3_pending_request_respond, or ask the user; the task's completion still wakes this thread. If pendingApproval is set, read the fresh full request in childThreadId before responding: the record can outlive its provider callback. It grants no approval authority; decisions remain with the child's existing approval holder, and pending request tools answer questions only. The child keeps running and its completion still wakes this thread. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -116,6 +117,25 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { .annotate(Tool.Title, "Stop a workflow") .annotate(Tool.Destructive, true); +const BackgroundWaitTool = Tool.make("t3_background_wait", { + description: + "Keep this thread in the user's Working list and out of their inbox while you wait for background work. Start a background command or Monitor first. Call this before you end your turn if you will act on its result when it wakes you. In taskIds, give the ID shown when the task started. The thread stays in Working until the task ends. The user is not told the work is done. Do not mark work you leave running, such as a dev server or watcher. Unmarked commands and monitors never keep the thread in Working. Pass waiting=false to remove the mark. This does not stop the task. IDs that are not running return in unmatched and change nothing. Only Claude threads support this tool. Other providers return an error.", + parameters: Schema.Struct({ + taskIds: Schema.Array(TrimmedNonEmptyString).check( + Schema.isMinLength(1), + Schema.isMaxLength(20), + ), + waiting: Schema.optional(Schema.Boolean), + }), + success: Schema.Struct({ + matched: Schema.Array(Schema.String), + unmatched: Schema.Array(Schema.String), + }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies, +}).annotate(Tool.Title, "Wait on background work"); + const ScheduleTaskTool = Tool.make("schedule_task", { description: "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", @@ -279,6 +299,7 @@ export const OrchestratorToolkit = Toolkit.make( TaskStatusTool, TaskCancelTool, WorkflowStopTool, + BackgroundWaitTool, ScheduleTaskTool, ListScheduledTasksTool, UpdateScheduledTaskTool, diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 88a260ed6d3c..8d675c839756 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -252,11 +252,7 @@ const PreviewScrollTool = safeBrowserTool( }).annotate(Tool.Title, "Scroll preview page"), ); -/** - * MCP `structuredContent` must be a JSON object, and Claude Code rejects the - * whole result when it is not. Wrapping keeps arrays, strings, numbers, and - * null valid instead of failing only for non-object expressions. - */ +/** Wrap non-objects: MCP requires object structuredContent, and Claude Code rejects invalid results entirely. */ export const PreviewEvaluateResult = Schema.Struct({ ...presentationFields, value: Schema.Unknown.annotate({ diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 380a1b52ec96..9522c8d28fec 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -27,9 +27,7 @@ function projectFailure(error: Project.ProjectServiceError) { return new OrchestratorMcpFailure({ code: "invalid_request", message }); } -/** - * Without this check a launch could point an agent at any directory on the machine. - */ +/** Require project worktrees so launches cannot target arbitrary host directories. */ const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* ( workspaceRoot: string, worktreePath: string, @@ -105,8 +103,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); } - // A launch is accepted before its worktree is provisioned, so a base - // ref that cannot resolve is refused while the agent can still act on it. + // Reject unresolved base refs before accepting a launch; worktree provisioning happens later. const strategy = input.workspaceStrategy; if (strategy?.type === "worktree") yield* ThreadLaunch.ThreadLaunchService.pipe( @@ -233,8 +230,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { Effect.gen(function* () { const projects = yield* Project.ProjectService; if (workspaceRoot === undefined) { - // Project creation records no model default (only an update does), so - // reject what this mode would otherwise drop silently. + // Only updates record model defaults; reject defaults that creation would silently drop. if ( input.scripts !== undefined || input.createWorkspaceRootIfMissing !== undefined || diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index c49715eb7eb7..507381531ac4 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -9,6 +9,7 @@ import { type PullRequestRef, type PullRequestReviewGuideResult, type ThreadPullRequestLink, + SourceControlProviderKind, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -64,7 +65,7 @@ function makeProject( remoteName: "origin", remoteUrl: "git@github.com:T3Tools/T3Code.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "T3Tools/T3Code", owner: "T3Tools", name: "T3Code", @@ -265,24 +266,6 @@ describe("pull request toolkit handlers", () => { }), ); - it.effect("links a GitCafe pull request by its canonical /pull/ URL", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - const result = yield* harness.call("link_pull_request", { - url: "https://git.cafe/pingdotgg/t3code/pull/23897", - }); - expect(result).toMatchObject({ - host: "git.cafe", - repository: "pingdotgg/t3code", - number: 23897, - alreadyLinked: false, - }); - expect(yield* Ref.get(harness.commands)).toMatchObject([ - { type: "thread.pull-request.link", host: "git.cafe", number: 23897 }, - ]); - }), - ); - it.effect("watching an unlinked pull request links it first", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -370,6 +353,39 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("canonicalizes GitCafe links independently of the thread's project host", () => + Effect.gen(function* () { + for (const host of ["git.cafe", "staging.git.cafe"]) { + for (const project of [makeProject(), makeProject(null)]) { + for (const target of [ + { host, repository: "PingDotGG/T3Code", number: 21353 }, + { url: `https://${host}/PingDotGG/T3Code/pull/21353` }, + { url: `https://${host}/PingDotGG/T3Code/pulls/21353` }, + ]) { + const harness = yield* makeHarness({ project }); + const result = yield* harness.call("link_pull_request", target); + const url = `https://${host}/pingdotgg/t3code/pulls/21353`; + expect(result).toMatchObject({ + host, + repository: "pingdotgg/t3code", + number: 21353, + url, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.pull-request.link", + host, + repository: "pingdotgg/t3code", + number: 21353, + url, + }, + ]); + } + } + } + }), + ); + it.effect("builds the URL in the project host's own shape", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -380,7 +396,7 @@ describe("pull request toolkit handlers", () => { remoteName: "origin", remoteUrl: "git@gitlab.com:group/sub/project.git", }, - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), displayName: "group/sub/project", }), }); @@ -390,44 +406,22 @@ describe("pull request toolkit handlers", () => { }); expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); expect(result.host).toBe("gitlab.com"); - }), - ); - - it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - project: makeProject({ - canonicalKey: "forge.example/git/owner/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "http://forge.example:3000/git/owner/repo.git", - }, - provider: "forgejo", - displayName: "git/owner/repo", - }), - }); - const result = yield* harness.call("link_pull_request", { - repository: "git/owner/repo", - number: 42, - }); - expect(result).toEqual({ - host: "forge.example:3000", - repository: "git/owner/repo", - number: 42, - url: "http://forge.example:3000/git/owner/repo/pulls/42", - alreadyLinked: false, - }); const other = yield* harness.call("link_pull_request", { - host: "other.example", - repository: "owner/repo", - number: 42, + host: "gitlab.example.test", + repository: "team/repo", + number: 7, }); - expect(other.url).toBe("https://other.example/owner/repo/pull/42"); + expect(other.url).toBe("https://gitlab.example.test/team/repo/-/merge_requests/7"); + const onGitHub = yield* harness.call("link_pull_request", { + host: "github.com", + repository: "acme/web", + number: 9, + }); + expect(onGitHub.url).toBe("https://github.com/acme/web/pull/9"); }), ); - it.effect("links Azure DevOps Server URLs and numeric references without a known provider", () => + it.effect("links a URL but not a bare number when the project's host is unknown", () => Effect.gen(function* () { const harness = yield* makeHarness({ project: makeProject({ @@ -437,17 +431,14 @@ describe("pull request toolkit handlers", () => { remoteName: "origin", remoteUrl: "http://dummy:secret@scm.example.test:8080/tfs/Collection/Project/_git/Repo", }, - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), displayName: "tfs/collection/project/_git/repo", }), }); - const generated = - "http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"; - const fromNumber = yield* harness.call("link_pull_request", { - repository: "tfs/Collection/Project/_git/Repo", - number: 124, - }); - expect.soft(fromNumber.url).toBe(generated); + const error = yield* harness + .call("link_pull_request", { repository: "tfs/Collection/Project/_git/Repo", number: 124 }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestUrlUnavailableError" }); const supplied = "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123"; const fromUrl = yield* harness.call("link_pull_request", { url: supplied }); @@ -459,7 +450,6 @@ describe("pull request toolkit handlers", () => { alreadyLinked: false, }); expect(yield* Ref.get(harness.commands)).toMatchObject([ - { type: "thread.pull-request.link", number: 124, url: generated }, { type: "thread.pull-request.link", number: 123, url: supplied }, ]); }), @@ -618,7 +608,7 @@ describe("pull request guide tools", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/web.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "acme/web", owner: "acme", name: "web", @@ -677,7 +667,7 @@ describe("pull request guide tools", () => { remoteName: "origin", remoteUrl: "https://dev.azure.com/org/project/_git/web", }, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), displayName: "org/project/_git/web", owner: "org", name: "web", diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index c777214aad5c..fcd9f8bfef5c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,24 +1,12 @@ -import { - threadPullRequestsOf, - threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; import { CommandId, - pullRequestHostOf, type OrchestrationProjectShell, type OrchestrationV2ThreadShell, type SourceControlProviderKind, type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { - normalizeThreadPullRequestKey, - resolveThreadPullRequestChains, - threadPullRequestKeyOf, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -28,6 +16,15 @@ import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; import { pullRequestSyncKey } from "../../../pullRequest/pullRequestSyncKey.ts"; +import { sourceControlDefinitions } from "../../../sourceControl/sourceControlDefinitions.ts"; +import { + normalizeThreadPullRequestKey, + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + threadPullRequestsOf, + visibleThreadPullRequests, +} from "../../../sourceControl/threadPullRequests.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -36,6 +33,7 @@ import { PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestUnlinkFailedError, PullRequestListFailedError, PullRequestNotOpenError, @@ -62,10 +60,10 @@ function projectHostAndProvider(project: OrchestrationProjectShell | undefined): readonly kind: SourceControlProviderKind | null; } { const identity = project?.repositoryIdentity; - const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; + const kind = identity?.provider ?? null; if (!identity || kind === null) return { host: null, kind: null }; return { - host: pullRequestHostOf(identity, kind), + host: sourceControlDefinitions.changeRequestHost(identity, kind), kind, }; } @@ -75,11 +73,14 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( project: OrchestrationProjectShell | undefined, ) { if (input.url !== undefined) { - const parsed = parseChangeRequestUrl(input.url); - if (parsed === null) { + const parsed = sourceControlDefinitions.parseChangeRequestUrl(input.url)?.link; + if (parsed === undefined) { return yield* new PullRequestUrlInvalidError({}); } - return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget; + return { + ...normalizeThreadPullRequestKey(parsed), + url: canonicalizeGitCafePullRequestUrl(input.url), + } satisfies ResolvedTarget; } if (input.repository === undefined || input.number === undefined) { return yield* new PullRequestTargetIncompleteError({}); @@ -90,27 +91,27 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( return yield* new PullRequestHostRequiredError({}); } const repository = input.repository.toLowerCase(); + // Without a URL, a public forge host says what shape its change request URLs take; + // any other host is taken to run the project's own forge. + const recognizedKind = sourceControlDefinitions.recognizeRemote(`https://${host}`)?.kind; + const kind = + sourceControlDefinitions.findByPublicHost(host)?.kind ?? + (recognizedKind === "gitcafe" ? recognizedKind : projectHost.kind); const url = - changeRequestUrlFor( - host === projectHost.host ? projectHost.kind : null, - host, - repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) ?? - (/(?:^|\/)_git\/[^/]+$/u.test(repository) - ? changeRequestUrlFor( - "azure-devops", + kind === null + ? null + : sourceControlDefinitions.get(kind).changeRequestUrl({ host, repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) - : null) ?? - `https://${host}/${repository}/pull/${input.number}`; + number: input.number, + remoteUrl: project?.repositoryIdentity?.locator.remoteUrl, + }); + if (url === null) { + return yield* new PullRequestUrlUnavailableError({}); + } return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), - url, + url: canonicalizeGitCafePullRequestUrl(url), } satisfies ResolvedTarget; }); @@ -164,8 +165,7 @@ function projectForPullRequest( target: { readonly host: string; readonly repository: string }, preferred: OrchestrationProjectShell | undefined, ): OrchestrationProjectShell | undefined { - // The same rule the guide service uses: a pull request reads through a project whose checkout - // names it with the same host-level key, which also maps Azure DevOps' org/project/_git/repo. + // Match the guide service's host-level repository key, including Azure DevOps org/project/_git/repo. const matches = (project: OrchestrationProjectShell) => { const own = pullRequestSyncKey( { projectId: project.id, host: target.host, repository: target.repository, number: 1 }, @@ -177,7 +177,8 @@ function projectForPullRequest( : pullRequestSyncKey( { projectId: project.id, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? "", + repository: + sourceControlDefinitions.repositorySelector(project.repositoryIdentity) ?? "", number: 1, }, project.repositoryIdentity, @@ -314,8 +315,18 @@ const make = Effect.gen(function* () { .listShells() .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause }))); const callerProject = known.find((project) => project.id === callerThread?.projectId); + // A pull request named by host and repository builds its URL through the project that holds + // it, which need not be the caller's. + const urlProject = (input: PullRequestTargetInput) => + input.url === undefined && input.host !== undefined && input.repository !== undefined + ? (projectForPullRequest( + known, + { host: input.host, repository: input.repository }, + callerProject, + ) ?? callerProject) + : callerProject; return yield* Effect.forEach(targets, (input) => - resolveTarget(input, callerProject).pipe( + resolveTarget(input, urlProject(input)).pipe( Effect.map((target) => ({ input, target, diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index a3a75eabaa38..63c0efa1684a 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -43,7 +43,7 @@ export const PullRequestTargetInput = Schema.Struct({ url: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.", + "The pull request's web URL as its host shows it, for example https://git.example.com/owner/repo/pull/123 or https://git.example.com/group/project/-/merge_requests/123. Preferred when you have it; host, repository and number are read from it.", }), ), repository: Schema.optional( @@ -60,7 +60,7 @@ export const PullRequestTargetInput = Schema.Struct({ host: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.", + "Host the repository lives on, for example git.example.com. Defaults to the host of this thread's project.", }), ), }); @@ -93,6 +93,15 @@ export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestUrlUnavailableError", + {}, +) { + override get message(): string { + return "This thread's project is on a host with no known pull request URL shape. Pass url."; + } +} + export class PullRequestThreadRequiredError extends Schema.TaggedError()( "PullRequestThreadRequiredError", {}, @@ -189,6 +198,7 @@ export const PullRequestToolError = Schema.Union([ PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestThreadRequiredError, PullRequestThreadNotFoundError, PullRequestLinkFailedError, @@ -250,7 +260,7 @@ export const ThreadPullRequestEntry = Schema.Struct({ stack: Schema.NullOr( Schema.Struct({ kind: Schema.Literals(["native", "derived"]), - /** 1-based layer, bottom of the stack first. Pull requests built on the same one share it. */ + /** 1-based layer from the bottom; siblings share a layer. */ position: Schema.Int, size: Schema.Int, }), diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 4ab78b4fe07b..1a841542eac9 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -104,16 +104,10 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { t3_thread_search: McpToolAccess.reads((input) => Effect.gen(function* () { const { caller } = yield* readCaller(); - const { projectId: requested, ...query } = input; - const projectId = requested ?? caller?.projectId; const threadSearch = yield* ThreadSearch.ThreadSearch; - const result = yield* threadSearch.search(query).pipe(Effect.mapError(unavailable)); - return { - matches: - projectId === undefined - ? result.matches - : result.matches.filter((match) => match.projectId === projectId), - }; + return yield* threadSearch + .searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId }) + .pipe(Effect.mapError(unavailable)); }), ), t3_thread_fork: writesThread((input) => diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 21750cbb6eaa..c23f032522eb 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -2,7 +2,7 @@ import { ScheduledTaskId, ScheduledTask, OrchestrationSearchThreadsInput, - OrchestrationSearchThreadsResult, + OrchestrationThreadSearchMatch, OrchestrationV2ThreadForkSourcePoint, OrchestrationV2ContextTransfer, TrimmedNonEmptyString, @@ -243,12 +243,23 @@ const ThreadTransfersTool = Tool.make("t3_thread_transfers", { const ThreadSearchTool = Tool.make("t3_thread_search", { ...commandTool, description: - "Search active thread titles and content with the app's existing bounded search. Matches are limited to one project (projectId, else the calling thread's project) out of the global top matches, so this may return fewer than limit. A caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.", + 'Search active threads the way the command palette does: title, branch, pull request, and project matches first (source "title", ranked by match quality and recent use, with one-typo tolerance), then threads whose messages contain every query word of three or more characters (source "user" or "assistant", with a snippet; up to a fifth of the limit is kept for these). Subagent threads are skipped. Limited to one project (projectId, else the calling thread\'s project); a caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.', parameters: Schema.Struct({ ...OrchestrationSearchThreadsInput.fields, projectId: Schema.optional(ProjectId), }), - success: OrchestrationSearchThreadsResult, + success: Schema.Struct({ + matches: Schema.Array( + Schema.Struct({ + threadId: OrchestrationThreadSearchMatch.fields.threadId, + projectId: OrchestrationThreadSearchMatch.fields.projectId, + title: Schema.String, + source: Schema.Literals(["title", "user", "assistant"]), + snippet: Schema.optional(OrchestrationThreadSearchMatch.fields.snippet), + messageCreatedAt: Schema.optional(OrchestrationThreadSearchMatch.fields.messageCreatedAt), + }), + ), + }), dependencies: [...commandTool.dependencies, ThreadSearch.ThreadSearch], }) .annotate(Tool.Readonly, true) diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 61d36c91747b..18c261602b03 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -43,10 +43,7 @@ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { const WorktreeStatusTool = Tool.make("t3_worktree_status", { description: "Needs an agent running inside a T3 thread. Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for t3_worktree_handoff's startFromOrigin. Call this before t3_worktree_handoff to check whether a handoff is possible or has already happened.", - // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes - // to a top-level `type: "object"` JSON Schema. An explicit empty - // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a - // valid MCP tool input schema and makes clients reject the whole server. + // Empty Struct emits object|array, making MCP clients reject the server; use EmptyParams for an object schema. success: WorktreeMcpStatusResult, failure: WorktreeToolFailure, failureMode: "return", diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts index d50236ce6cd1..6e563e10b56f 100644 --- a/apps/server/src/mcpApps/McpAppModelContext.ts +++ b/apps/server/src/mcpApps/McpAppModelContext.ts @@ -24,7 +24,7 @@ export interface McpAppModelContextEntry { export class McpAppModelContext extends Context.Service< McpAppModelContext, { - /** Replaces the app's context; empty text removes it. */ + /** Empty text removes the app's context. */ readonly set: (input: { readonly threadId: ThreadId; readonly itemId: TurnItemId; @@ -67,9 +67,7 @@ export const layer = Layer.effect( }); const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) { - // The item can live in a thread this one was forked from, so it is - // matched by id alone; that thread rolling the run back later leaves the - // fork's copy in place. + // Match by id across fork lineage; later source rollbacks leave the fork's copy intact. return yield* sql` SELECT context.item_id AS "itemId", context.server, context.tool, context.text FROM mcp_app_model_context AS context diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index 6e97a726a021..1387cc6b20c7 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -26,10 +26,7 @@ import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionMana import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; -/** - * A stopped session is reported, never started: an app view must not spend a - * provider turn. - */ +/** Reports stopped sessions without starting them; app views must not spend provider turns. */ export class McpAppRequests extends Context.Service< McpAppRequests, { @@ -133,8 +130,7 @@ const make = Effect.gen(function* () { const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) { const resolved = yield* resolve(input); - // The spec forbids apps calling tools hidden from them; checked here too, - // since a client's own check is only a courtesy to its user. + // Enforce hidden-tool restrictions server-side; client checks cannot enforce the spec. const tool = yield* findTool(input.threadId, resolved, input.name); if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { return yield* fail(input.threadId, "tool-not-callable"); @@ -184,8 +180,7 @@ const make = Effect.gen(function* () { input: McpAppUpdateModelContextInput, ) { const { app } = yield* resolveApp(input); - // Context belongs to the conversation on screen, which only shows this app - // when it is the app's own thread or one forked from it. + // Context belongs only to the app's thread or its descendants. if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) { return yield* fail(input.threadId, "not-an-app"); } diff --git a/apps/server/src/mcpApps/McpAppSnapshot.ts b/apps/server/src/mcpApps/McpAppSnapshot.ts index f47237f5c82c..a43c01a40417 100644 --- a/apps/server/src/mcpApps/McpAppSnapshot.ts +++ b/apps/server/src/mcpApps/McpAppSnapshot.ts @@ -26,9 +26,7 @@ function readMcpAppDocument( if (content.uri !== undefined && content.uri !== uri) continue; const mimeType = typeof content.mimeType === "string" ? content.mimeType : ""; if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue; - // Sizes are checked before decoding, so an oversized resource is - // refused without first being copied: a string's UTF-8 form is at least - // its length, and base64 decodes to three quarters of its own. + // Bound before decoding to avoid oversized copies; UTF-8 is at least string length, base64 decodes to 3/4. const html = typeof content.text === "string" ? content.text.length > MCP_APP_MAX_HTML_BYTES @@ -79,8 +77,7 @@ export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (in const ui = Predicate.isObject(document.meta) ? document.meta : {}; const csp = readMcpAppCsp(ui.csp); const fileSystem = yield* FileSystem.FileSystem; - // Only the returned reference lets thread deletion find the document, so a - // write that fails or is interrupted removes it. + // Failed or interrupted writes remove documents that thread deletion cannot yet find. yield* fileSystem .writeFileString(filePath, injectMcpAppCsp(html, csp)) .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore))); diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts index ee197dd0d7d3..1802a4a66591 100644 --- a/apps/server/src/observability/BrowserTraceCollector.ts +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -10,7 +10,7 @@ export class BrowserTraceCollector extends Context.Service< } >()("t3/observability/BrowserTraceCollector") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => BrowserTraceCollector.of({ record: (records) => diff --git a/apps/server/src/observability/DefectReporter.ts b/apps/server/src/observability/DefectReporter.ts index 4e8911c1493d..39e93ba5fb44 100644 --- a/apps/server/src/observability/DefectReporter.ts +++ b/apps/server/src/observability/DefectReporter.ts @@ -2,10 +2,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as ErrorReporter from "effect/ErrorReporter"; -/** - * RpcServer reports every failed handler exit; typed failures are expected responses, so only - * dies are logged. - */ +// RpcServer reports typed failures too; log only defects. const reporter: ErrorReporter.ErrorReporter = { [ErrorReporter.TypeId]: ErrorReporter.TypeId, report: ({ cause, fiber }) => { diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index 767e10bce8fb..a3a58e60e4e2 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -25,7 +25,6 @@ const stalled: EventLoopMonitor.EventLoopReadings = { describe("EventLoopMonitor", () => { it("ignores delay the loop spent idle, such as a system sleep", () => { - // Waking from sleep reads as a long gap, but the loop was idle in poll for it. const asleep: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(600_000), diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index 5e4d55ff59bd..ed3befd0bb67 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -18,10 +18,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; import * as Metrics from "./Metrics.ts"; -// Node's delay histogram wakes a native timer every RESOLUTION_MS and records the -// gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S -// reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can -// undercount a stall by up to RESOLUTION_MS. +// Subtract the histogram's timer resolution; this can undercount stalls by up to RESOLUTION_MS. const RESOLUTION_MS = 1000; const STALL_THRESHOLD_MS = 2000; const SAMPLE_INTERVAL = "30 seconds"; @@ -113,8 +110,7 @@ const makeNodeSampler = Effect.gen(function* () { export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; - // libuv's clock keeps running while the system sleeps on macOS and Windows, so a - // sleep also reads as delay, but the loop spent it idle in poll. + // libuv clocks run during macOS/Windows sleep; idle poll time distinguishes it from stalls. if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; return delayMs; }; @@ -216,10 +212,7 @@ const layerWith = ( } }).pipe(sampling.withPermits(1)); const wait = Effect.sleep(SAMPLE_INTERVAL); - // The layer builds before the rest of the server, so the first sample covers - // startup work such as migrations and projection bootstrap. That can block the - // loop for seconds on a large database, so skip it rather than warn at every - // launch. + // The first sample includes migrations and bootstrap; skip expected startup stalls. yield* wait.pipe( Effect.andThen(sampling.withPermits(1)(sample)), Effect.andThen(Ref.set(warmedUp, true)), diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index a2cbab9ed571..0f1aeb8beac1 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -201,8 +201,7 @@ const recordMetrics = } }); -// Durations come from the monotonic clock, so wall-clock corrections cannot skew them, and -// metrics are recorded in an exit finalizer, so interrupted work is counted as "interrupt". +// Monotonic timing ignores clock corrections; exit finalizers count interruptions. const withMetricsImpl = ( effect: Effect.Effect, options: WithMetricsOptions, diff --git a/apps/server/src/observability/Observability.ts b/apps/server/src/observability/Observability.ts index 05c382ea3828..343851e3d1d1 100644 --- a/apps/server/src/observability/Observability.ts +++ b/apps/server/src/observability/Observability.ts @@ -23,8 +23,7 @@ export const layer = Layer.unwrap( const traces = config.otlpTracesExport; const metrics = config.otlpMetricsExport; - // The trace serializer stays in the returned context because the browser - // trace forwarder exports on the same signal. + // Keep serialization in context for the browser trace forwarder's exporter. const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol); const resource = ServerConfig.otlpResource(config, yield* HostProcess.Hostname); const attribution = yield* ResourceAttribution.ResourceAttribution; @@ -32,8 +31,7 @@ export const layer = Layer.unwrap( const layerTraceReferences = Layer.mergeAll( Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), - // Keeps each SQL statement's span current while it waits for a connection, - // so the client can record the lock wait on it. + // Preserve SQL spans while awaiting connections so lock waits can be recorded on them. Layer.succeed(Statement.SpanPropagationEnabled, true), HttpObservability.layer, ); diff --git a/apps/server/src/observability/OtlpEndpointCheck.ts b/apps/server/src/observability/OtlpEndpointCheck.ts index 6a136a2ff7ab..cb764f4557d9 100644 --- a/apps/server/src/observability/OtlpEndpointCheck.ts +++ b/apps/server/src/observability/OtlpEndpointCheck.ts @@ -58,9 +58,7 @@ const make = Effect.gen(function* () { return emptyExport(serialization, signal); }).pipe(Effect.provide(layerOtlpSerialization(signalExport.protocol))); const startedAt = yield* Clock.currentTimeMillis; - // Only the status matters. The scope aborts the request once it is read, so a receiver - // that keeps its body open cannot hold the connection. Tracing stays off, as it does for - // real exports, because request spans record the URL and custom credential headers. + // Close after reading status so open bodies cannot hang; disable tracing to keep URLs and credentials out of spans. const response = yield* HttpClient.withScope(httpClient) .post(url, { body, headers }) .pipe( diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 203997ffdf1e..cd77d5ee379c 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -9,10 +9,7 @@ import { rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts" type WsRpcMethod = RpcGroup.Rpcs["_tag"]; -/** - * Trace queries and dashboards group by these labels, so they keep their historical values - * even where they differ from the method prefix. - */ +// Trace queries and dashboards depend on these labels, including deviations from method prefixes. const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", @@ -24,6 +21,8 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.searchThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: "orchestration", @@ -97,6 +96,7 @@ const RPC_AGGREGATES = { [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", [WS_METHODS.secretsAnswerRequest]: "secrets", + [WS_METHODS.guestShareCreate]: "guest-share", [WS_METHODS.cloudGetRelayClientStatus]: "cloud", [WS_METHODS.cloudInstallRelayClient]: "cloud", [WS_METHODS.pullRequestsList]: "pull-requests", @@ -236,10 +236,7 @@ const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet = new Set([ WS_METHODS.serverSignalProcess, ]); -/** - * `ws.ts` adds it to the server's group after `RpcScopeAuthorization`, so it wraps - * authorization and also records rejected calls. - */ +/** Add after RpcScopeAuthorization to wrap authorization and record rejected calls. */ export class RpcInstrumentation extends RpcMiddleware.Service()( "t3/server/RpcInstrumentation", ) {} diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 1a0c6caf1f92..73b66f2dc39c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -53,6 +53,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; @@ -398,6 +399,151 @@ function assertReinjectedInstructions( } describe("AcpAdapterV2", () => { + it.live.each(["details", "none"] as const)( + "fails the turn on an ACP v2 idle error with %s, settles its open tool, and accepts the next prompt", + (details) => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make(`idle-error-${details}`); + const threadId = ThreadId.make(`thread-idle-error-${details}`); + const childSessionId = "mock-child-session-idle-error"; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + extractBackgroundTaskId: (toolCall) => + toolCall.toolCallId === "tool-call-idle-error-monitor" + ? "idle-error-monitor" + : undefined, + extractSubagentUpdate: (toolCall) => + toolCall.toolCallId === "tool-call-idle-error-subagent" + ? { + nativeTaskId: "task-idle-error", + prompt: "child work", + title: "child work", + model: null, + status: toolCall.status === "completed" ? "completed" : "running", + childSessionId, + result: null, + suppressNormalTool: true, + } + : undefined, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + environment: { T3_ACP_IDLE_ERROR: details }, + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`session-idle-error-${details}`), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const runTurn = (ordinal: number) => + Effect.gen(function* () { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal, + }), + ); + return yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + }); + + const failedEvents = yield* runTurn(1); + const failed = failedEvents.at(-1); + assert.equal(failed?.type, "turn.terminal"); + if (failed?.type !== "turn.terminal" || failed.status !== "failed") { + return assert.fail("expected a failed terminal"); + } + assert.isTrue( + failed.childTurnsContinue, + "the run keeps ingesting work that outlives the failed prompt", + ); + const lateSubagent = yield* runtime.events.pipe( + Stream.filter( + (event) => event.type === "subagent.updated" && event.subagent.status === "completed", + ), + Stream.runHead, + Effect.timeoutOption("2 seconds"), + ); + assert.isTrue( + Option.isSome(lateSubagent) && Option.isSome(lateSubagent.value), + "a subagent finishing after the failed prompt still reports to the run", + ); + assert.deepEqual( + failed.failure, + details === "details" + ? { + class: "provider_error", + message: "Missing Authentication header", + code: "-32000", + retryable: null, + } + : { + class: "provider_error", + message: "Provider turn failed.", + code: null, + retryable: null, + }, + ); + const toolItems = (nativeId: string) => + failedEvents.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId + ? [event.turnItem] + : [], + ); + const toolStatuses = toolItems("tool-call-idle-error-1").map((item) => item.status); + assert.include(toolStatuses, "running"); + assert.equal(toolStatuses.at(-1), "failed"); + assert.deepEqual( + toolItems("tool-call-idle-error-monitor").map((item) => item.status), + ["running"], + "a background monitor outlives the failed turn and keeps its running row", + ); + const childItems = toolItems("task-idle-error:tool:tool-call-idle-error-child"); + assert.deepEqual( + childItems.map((item) => item.status), + ["running"], + "a child-session tool keeps running in its own thread", + ); + assert.isTrue(childItems.every((item) => item.threadId !== threadId)); + assert.isEmpty( + toolItems("tool-call-idle-error-child"), + "the failed turn must not reproject the child tool as a root tool", + ); + + const nextEvents = yield* runTurn(2); + const next = nextEvents.at(-1); + assert.equal(next?.type === "turn.terminal" ? next.status : undefined, "completed"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // Factory Droid 0.243.0 sends an optional text companion for every AskUser choice. const droidChoice = { type: "string", @@ -1225,8 +1371,7 @@ describe("AcpAdapterV2", () => { ); type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; let handler: Parameters[0] | undefined; - // Devin streams a file write as it generates it: each update resends the - // whole file so far, a few characters longer, with no status. + // Devin resends the growing file without status on each update. const file = `${"x = 1\n".repeat(800)}END_OF_FILE\n`; const chunks = 200; const streamedWrite = ( @@ -3081,6 +3226,72 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("settles a Grok background command that completed before an ACP v2 idle error", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("grok-idle-error-background"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_IDLE_ERROR: "grok-background" }, + })(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)), + }); + const threadId = ThreadId.make("grok-idle-error-background"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-idle-error-background"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminal = events.at(-1); + assert.equal(terminal?.type === "turn.terminal" ? terminal.status : undefined, "failed"); + const command = events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "call-idle-error-background", + ); + assert.equal( + command?.type === "turn_item.updated" ? command.turnItem.status : undefined, + "completed", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // Oracle: Grok incident frames. Command completion must release the turn even // when the CLI keeps the prompt RPC and process alive. Command polls stay visible. it.live( @@ -3169,8 +3380,91 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - // Oracle: #15888 KillTask reports. Command completion must release the turn even - // when the CLI keeps the prompt RPC and process alive. Kill calls stay visible. + // Grok expands only a leading `/name`, so a picked leading `$name` + // must reach it in that form; anything else keeps the user's text. + it.live.each([ + { text: "$deploy ship it", sent: "/deploy ship it", native: true }, + { text: "ship $deploy", sent: "ship $deploy", native: false }, + { text: "$other ship it", sent: "$other ship it", native: false }, + ])("Grok sends $text with a picked skill as $sent", ({ text, sent, native }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const hostPlatform = yield* HostProcess.Platform; + // Stands in for `grok inspect --json`, the catalog behind the `$` picker. + const catalog = { skills: [{ name: "deploy", source: { path: "/s/deploy/SKILL.md" } }] }; + const grokPath = writeFakeCli({ + directory: yield* fileSystem.makeTempDirectoryScoped(), + name: "grok", + source: `process.stdout.write(${JSON.stringify(`${JSON.stringify(catalog)}\n`)});\n`, + platform: hostPlatform, + }); + const protocolEvents = yield* Queue.unbounded(); + const instanceId = ProviderInstanceId.make("grok-skill-mention"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: { ...DEFAULT_GROK_SETTINGS, binaryPath: grokPath }, + // The fake CLI needs `node` on its PATH. + environment: { ...process.env }, + hostPlatform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe( + Effect.flatMap(makeXAiPromptCompletionRuntime), + ), + }); + const threadId = ThreadId.make("grok-skill-mention"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-skill-mention"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + messageText: text, + }), + ); + const prompt = yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); + const blocks = rawProtocolPromptTextBlocks(Option.getOrThrow(prompt)); + if (native) { + // Same shape as a typed native command: one bare text block, no T3 instructions. + assert.deepEqual(blocks, [{ type: "text", text: sent }]); + } else { + assert.isTrue(blocks.some((block) => block.text.includes(sent))); + assert.isFalse(blocks.some((block) => block.text.trimStart().startsWith("/"))); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + // Command completion must release the turn even when the CLI keeps the prompt RPC and process + // alive. Kill calls stay visible. it.live.each([ { shape: "output_only", expectedCommand: { type: "command_execution", status: "completed" } }, { shape: "variant_only", expectedCommand: { type: "command_execution", status: "completed" } }, @@ -4626,8 +4920,7 @@ describe("AcpAdapterV2", () => { assert.isTrue(yield* hasPendingBackgroundWork); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // A production Grok spawn ACK has raw tool status completed but extracts - // as a running subagent with its original non-empty prompt. + // Grok reports spawn ACKs as completed tools with running subagents and non-empty prompts. yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index 6f8333dd27fb..bb74f75bd346 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -203,8 +203,7 @@ export function makeAcpReplayRuntime(input: { EffectAcpErrors.AcpError, Crypto.Crypto | Scope.Scope > { - // Recorded transcripts outgrow the kernel's 128 KiB limit on one environment - // variable, so the agent reads the transcript from a file beside its status. + // Transcripts exceed the kernel's 128 KiB per-environment-variable limit. const transcriptPath = `${input.statusPath}.transcript.json`; return (runtimeInput) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 1ea491b76b71..3ec304b0a9a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -81,10 +81,7 @@ export interface AntigravityAdapterV2Options { readonly continuationRequests?: Parameters[0]["continuationRequests"]; } -/** - * ACP 1.1.1 exposes subagent invocations as ordinary `start_subagent` tools without child - * session ids. - */ +/** ACP 1.1.1 reports `start_subagent` tools without child session ids. */ const extractAntigravitySubagentUpdate: NonNullable = ( toolCall, ) => { @@ -158,8 +155,7 @@ export function makeAntigravityAcpAdapterFlavor( runtimeHarness: "Antigravity", capabilities: AntigravityProviderCapabilitiesV2, makeRuntime, - // Loading history replays every tool call; resume restores the session - // without the replay and is what the official client does. + // Loading history replays tool calls; resume avoids replay. preferResumeSession: true, subagentsIdleOnTurnCompletion: true, ...(options.onSessionEvent === undefined ? {} : { onSessionEvent: options.onSessionEvent }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1d06dd9268e5..23ed3b6a9932 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -35,6 +35,7 @@ import { type ModelSelection, NodeId, type OrchestrationV2AppThread, + type OrchestrationV2PendingBackgroundTask, type OrchestrationV2ProviderThread, ProjectId, ProviderInstanceId, @@ -300,6 +301,40 @@ describe("ClaudeAdapterV2 runtime query policy", () => { ); }); + it("pins Claude's auto tool search on through flag settings", () => { + const override = ClaudeAdapterV2.claudeToolSearchOverride; + for (const value of ["auto", "auto:10", "auto:99.5", "auto:x", "auto:Infinity"]) { + assert.equal(override(value), "true", value); + } + for (const value of [ + undefined, + "true", + "false", + "auto:0", + "auto:-5", + "auto:100", + "auto:1e2", + "auto:1_000", + "auto:1,000", + "AUTO", + ]) { + assert.isUndefined(override(value), String(value)); + } + + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-tool-search", + resume: false, + cwd: "/workspace", + sdkSettings: { env: { OTHER: "kept" } }, + toolSearchOverride: "true", + }); + assert.deepEqual((options.settings as { env?: Record }).env, { + OTHER: "kept", + ENABLE_TOOL_SEARCH: "true", + }); + }); + it("projects AskUserQuestion input with question text as the answer key", () => { const preview = "\n **Proposed change**\n\n```ts\n\tconst enabled = true;\n```\n "; assert.deepEqual( @@ -594,6 +629,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => { assert.equal(systemPrompt.type, "preset"); assert.equal(systemPrompt.preset, "claude_code"); assert.include(systemPrompt.append ?? "", "Use `delegate_task`"); + assert.include(systemPrompt.append ?? "", "call `t3_background_wait`"); const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options); assert.equal(logged.hasMcpServers, true); assert.notInclude(JSON.stringify(logged), "secret-claude-token"); @@ -1115,7 +1151,6 @@ describe("ClaudeAdapterV2 resumeThread", () => { }); const projectDir = path.join(configDir, "projects", "-workspace"); yield* fileSystem.makeDirectory(projectDir, { recursive: true }); - // What a CLI stopped before it saved the first prompt leaves behind. yield* fileSystem.writeFileString( path.join(projectDir, "native-thread-empty.jsonl"), [ @@ -1817,7 +1852,6 @@ function makeFakeClaudeCli(answerSetServers = true) { }); return child; }; - // The SDK closes the CLI by ending its stdin. const closed = () => stdins.length > 0 && stdins.every((stdin) => stdin.writableEnded); return { spawn, spawns, controlRequests, closed }; } @@ -2135,7 +2169,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const STALE_TASK_NOTIFICATION_RESULT_TEXT = "Stale task-notification origin text that must not appear."; - // Shape seen live after interrupt recovery: zero turns, no terminal_reason. const staleTaskNotificationResult = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000106", result: STALE_TASK_NOTIFICATION_RESULT_TEXT, @@ -2231,6 +2264,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const providerThreadReceipts = + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2358,6 +2395,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "plan.updated") { yield* Queue.offer(planReceipts, event); } + if (event.type === "provider_thread.updated") { + yield* Queue.offer(providerThreadReceipts, event); + } if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } @@ -2401,6 +2441,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { systemNoticeReceipts, runtimeRequestReceipts, planReceipts, + providerThreadReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -2868,6 +2909,96 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ); + + it.effect("pins tool search on when the user's Claude settings choose auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-tool-search-", + }); + const openedToolSearch = ( + settingsJson: string, + cwd = CLAUDE_TEST_RUNTIME_POLICY.cwd, + environment?: NodeJS.ProcessEnv, + ) => + Effect.scoped( + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(home, "settings.json"), settingsJson); + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: home }, + ...(environment === undefined ? {} : { environment }), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-tool-search"), + text: "Run git status.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + ...CLAUDE_TEST_RUNTIME_POLICY, + cwd, + }), + }), + ); + const settings = harness.getOpenedOptions()?.settings; + return typeof settings === "object" ? settings.env?.ENABLE_TOOL_SEARCH : undefined; + }), + ); + assert.equal(yield* openedToolSearch('{"env":{"ENABLE_TOOL_SEARCH":"auto"}}'), "true"); + assert.isUndefined(yield* openedToolSearch('{"env":{"ENABLE_TOOL_SEARCH":"false"}}')); + assert.isUndefined(yield* openedToolSearch("{}")); + + // Claude Code keeps the local file at the repository root, the main checkout's root in a + // worktree, and that file beats the user's settings. + const repo = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-repo-" }); + const worktree = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wt-" }); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "wt"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + yield* fileSystem.makeDirectory(path.join(repo, ".claude")); + yield* fileSystem.makeDirectory(path.join(repo, "packages", "app"), { recursive: true }); + const rootLocal = (value: string) => + fileSystem.writeFileString( + path.join(repo, ".claude", "settings.local.json"), + `{"env":{"ENABLE_TOOL_SEARCH":"${value}"}}`, + ); + const userAuto = '{"env":{"ENABLE_TOOL_SEARCH":"auto"}}'; + yield* rootLocal("auto"); + assert.equal(yield* openedToolSearch("{}", worktree), "true"); + yield* rootLocal("false"); + assert.isUndefined(yield* openedToolSearch(userAuto, worktree)); + assert.isUndefined(yield* openedToolSearch(userAuto, path.join(repo, "packages", "app"))); + + // Project settings beat user settings, and the process env applies only when no file sets it. + const project = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-project-" }); + yield* fileSystem.makeDirectory(path.join(project, ".claude")); + yield* fileSystem.writeFileString( + path.join(project, ".claude", "settings.json"), + '{"env":{"ENABLE_TOOL_SEARCH":"auto"}}', + ); + assert.equal( + yield* openedToolSearch('{"env":{"ENABLE_TOOL_SEARCH":"false"}}', project), + "true", + ); + assert.equal( + yield* openedToolSearch("{}", CLAUDE_TEST_RUNTIME_POLICY.cwd, { + ENABLE_TOOL_SEARCH: "auto", + }), + "true", + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); }); describe("Plan-mode tool approval", () => { @@ -3911,7 +4042,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (terminal.status !== "failed") return; assert.include(terminal.failure.message, "run `claude auth login`"); assert.include(terminal.failure.message, configDir); - // The message quotes its paths, which escapes Windows separators. assert.include(terminal.failure.message, JSON.stringify(cwd)); assert.notInclude(terminal.failure.message, "repeated API errors"); }).pipe( @@ -3971,7 +4101,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } return event.providerTurn; }; - // Rollback closes the live query and waits for its message stream to end. const makeRollbackHarness = makeWakeHarnessWithOptions({ close: (sdkMessages) => Queue.shutdown(sdkMessages), }); @@ -4131,7 +4260,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("never records a subagent uuid for a turn with no main-thread assistant message", () => Effect.gen(function* () { - // The continuation turn reopens the query, and rollback shut the first queue down. const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Queue.shutdown(sdkMessages), freshQueueOnReopen: true, @@ -4208,7 +4336,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); const next = yield* Queue.take(harness.terminalReceipts); assert.equal(next.status, "completed"); - // The reopened query must resume from the whole transcript, not pin a subagent uuid. assert.isUndefined(harness.getOpenedOptions()?.resumeSessionAt); }).pipe( Effect.provide( @@ -4263,11 +4390,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - // Claude reports a compaction's outcome on a status frame. A /compact - // then ends with a success result naming the command and no terminal - // reason, even when the compaction failed (#19327). The automatic case - // omits the terminal reason too, so only the missing command keeps it - // completed. + // Failed /compact reports success without a terminal reason; status frames carry outcomes, including automatic compaction. const result = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000613", result: compact ? "" : "Done.", @@ -4322,15 +4445,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { const [running, settled] = updates; assert.equal(running?.status, "running"); assert.equal(settled?.status, compaction === "success" ? "completed" : "failed"); + assert.equal( + settled?.type === "compaction" ? settled.summary : undefined, + compaction === "failed" + ? limited + ? compactError + : "Not enough messages to compact." + : undefined, + ); assert.equal(settled?.id, running?.id); assert.equal(settled?.ordinal, running?.ordinal); assert.deepEqual(settled?.startedAt, running?.startedAt); assert.isNotNull(settled?.completedAt); assert.isEmpty(harness.terminalEvents()); - yield* Queue.offer( + yield* Queue.offerAll( harness.sdkMessages, - compact ? claudeSdkFrame({ ...result, local_command: "compact" }) : result, + compact + ? [ + // Claude Code echoes the command's stdout as a synthetic assistant message. + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000615", + text: "Compacted.", + }), + local_command_source: "Compacted.", + }), + claudeSdkFrame({ ...result, local_command: "compact" }), + ] + : [result], ); const terminal = yield* Queue.take(harness.terminalReceipts); assert.deepEqual( @@ -4342,6 +4485,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { updates, ); assert.equal(terminal.status, status); + assert.equal( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + !compact, + ); if (terminal.status !== "failed") return; assert.equal(terminal.failure.class, "usage_limit"); assert.equal(terminal.failure.message, compactError); @@ -4356,6 +4506,70 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps an unreported /compact failure's reason on the compaction, not as a reply", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-compaction-unreported"), + text: "/compact", + attachments: [], + }), + ); + // Claude can refuse a /compact without a compact_result: the only reason is the result text. + yield* Queue.offerAll(harness.sdkMessages, [ + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: "00000000-0000-4000-8000-000000000620", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000622", + text: "Not enough messages to compact.", + }), + local_command_source: + "Not enough messages to compact.", + }), + claudeSdkFrame({ + ...makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000621", + result: "Not enough messages to compact.", + numTurns: 0, + terminalReason: null, + }), + local_command: "compact", + }), + ]); + yield* Queue.take(harness.terminalReceipts); + const settled = harness.events + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(settled?.status, "failed"); + assert.equal(settled?.summary, "Not enough messages to compact."); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each([ { name: "parent limit", parentErrors: ["rate_limit"], expectedLimit: true }, { @@ -4634,7 +4848,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { usage: { ...zeroUsage, input_tokens: 37_939, output_tokens: 184 }, }), usageSnapshotFrame("msg_first", "00000000-0000-4000-8000-000000000671", zeroUsage), - // The next request starts at zero again and its delta reports only output. usageStreamFrame({ type: "message_start", message: { id: "msg_second", usage: zeroUsage }, @@ -4685,13 +4898,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { message: { id: "msg_native", usage: startUsage }, }), usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000680", startUsage), - // Output-only deltas keep the input and cache counts from message_start. usageStreamFrame({ type: "message_delta", delta: { stop_reason: null, stop_sequence: null }, usage: { input_tokens: null, output_tokens: 20 }, }), - // A delta that restates input (server tools) replaces it, explicit zeros included. usageStreamFrame({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, @@ -4710,7 +4921,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000682", session_id: WAKE_NATIVE_SESSION, }), - // Usage for the message before the boundary no longer describes the context. usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000684", startUsage), usageStreamFrame({ type: "message_delta", @@ -6207,8 +6417,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Frames follow #19832: a full context window ends the turn with - // blocking_limit while the plan's rate-limit window is still allowed. + // Context exhaustion reports blocking_limit even when the rate-limit window is allowed. it.effect.each([ { windowStatus: "allowed", @@ -6321,7 +6530,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { WAKE_TASK_ID, ); - // A subagent's own background shell keeps the subagent waiting on it. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -6363,6 +6571,132 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("holds only the background tasks the agent marks awaited, until they end", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const awaitBackgroundTasks = harness.runtime.awaitBackgroundTasks; + if (awaitBackgroundTasks === undefined) { + throw new Error("Claude adapter runtime must expose awaitBackgroundTasks."); + } + const roster = (tasks: ReadonlyArray, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: tasks.map((taskId) => ({ + task_id: taskId, + task_type: "local_bash", + description: taskId, + })), + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const nextRoster = Effect.fn("nextRoster")(function* ( + matches: (tasks: ReadonlyArray) => boolean, + ) { + while (true) { + const { providerThread } = yield* Queue.take(harness.providerThreadReceipts); + const tasks = providerThread.pendingBackgroundTasks ?? []; + if (matches(tasks)) return tasks; + } + }); + const marks = (tasks: ReadonlyArray) => + tasks.map((task) => [task.taskId, task.awaited === true]); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-awaited-roster"), + text: "Run the tests in the background and the dev server.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + roster([WAKE_TASK_ID, "dev-server"], "00000000-0000-4000-8000-000000000301"), + ); + yield* nextRoster((tasks) => tasks.length === 2); + + const marked = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: [WAKE_TASK_ID, "already-finished"], + awaited: true, + }); + assert.deepStrictEqual(marked.matched, [WAKE_TASK_ID]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + [WAKE_TASK_ID, true], + ["dev-server", false], + ], + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + // A replacing level rebuilds every entry; the mark must survive it. + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000302"), + ); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks[0]?.taskId === "dev-server")), + [ + ["dev-server", false], + [WAKE_TASK_ID, true], + ], + ); + + // Once the task leaves the roster, a reused id starts unmarked. + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server"], "00000000-0000-4000-8000-000000000303"), + ); + yield* nextRoster((tasks) => tasks.length === 1); + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000304"), + ); + assert.deepStrictEqual(marks(yield* nextRoster((tasks) => tasks.length === 2)), [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ]); + + yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: true, + }); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + ["dev-server", true], + [WAKE_TASK_ID, false], + ], + ); + const released = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: false, + }); + assert.deepStrictEqual(released.matched, ["dev-server"]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => !tasks.some((task) => task.awaited === true))), + [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("keeps a running Claude monitor typed after many newer monitors end", () => Effect.scoped( Effect.gen(function* () { @@ -6745,8 +7079,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const nativeIds = ["native-thread-roster-a", "native-thread-roster-b"] as const; let allocateIndex = 0; - // Real two-process model: each openQuery owns its own message queue. - // A shared queue would mask sibling process death on replacement. + // A shared queue would hide sibling process death on replacement. const processQueues: Array<{ readonly nativeThreadId: string; readonly queue: Queue.Queue; @@ -7482,8 +7815,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("fails a prompt Claude completes without starting a turn for it", () => Effect.scoped( Effect.gen(function* () { - // Claude answers a prompt whose uuid its transcript already holds with - // a lone completed lifecycle frame and never runs a turn for it. + // Claude answers previously recorded prompt uuids with only a completed lifecycle frame. const harness = yield* makeWakeHarness; const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -7907,9 +8239,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* harness.until(() => harness.offeredMessages.length === 1); - // Live interleaving seen after interrupt recovery: a stale stopped - // task_notification and its task-notification-origin result arrive - // before the real root assistant stream. + // After interrupt recovery, stale task notifications and their results can precede the root stream. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -9339,8 +9669,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); const frames = [ - // The SDK can forward child frames before the task_started that - // registers their subagent. + // Child frames can precede the task_started that registers their subagent. ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000000321", @@ -9739,8 +10068,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const toolUseId = "toolu-subagent-model"; const parentModel = "claude-opus-4-6"; - // Claude stamps its own error frames, such as a usage-limit notice, as - // ""; that is not the model the subagent runs on. + // Claude stamps its error frames as "", regardless of the subagent model. const observedModel = source === "synthetic-after" ? "" : "claude-haiku-4-5-20251001"; yield* harness.runtime.startTurn( @@ -10393,9 +10721,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); - // Shaped like the recorded SendMessage ACK in - // claude_background_subagent_lifecycle: the text block is the JSON of - // tool_use_result, and "message" names the agent's short id. const resumeAck = { success: true, message: `Resuming agent ${SUBAGENT_TASK_ID.slice(0, 7)}`, @@ -11634,7 +11959,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageMark: true, itemMark: true, }); - // A brief is marked as not a script, so its text is never read as one. assert.deepEqual(subagentPrompt("task-plain-auditor"), { text: "Audit the last five commits.", messageMark: false, @@ -12775,7 +13099,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Line shapes copied from a Claude CLI 2.1.301 workflow member transcript. const transcriptLine = (entry: Record) => `${JSON.stringify({ isSidechain: true, agentId: "a1", timestamp: "2026-10-17T05:08:07.524Z", ...entry })}\n`; const memberAssistantLine = ( @@ -13029,8 +13352,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.scoped( Effect.gen(function* () { const harness = yield* makeWorkflowHarness; - // About 1.3 MB of early replies: more than the launch frame's read - // and the settled frame's read together, at 512 KB each. + // Exceeds the launch and settle reads combined, at 512 KB each. const filler = "x".repeat(16 * 1024); NodeFS.writeFileSync( NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), @@ -13392,8 +13714,7 @@ describe("ClaudeAdapterV2 query message stream", () => { let closed = false; let releaseRead = () => {}; const readStarted = Promise.withResolvers(); - // Never yields a message — the read stays pending until close() flips - // `closed` and releases the in-flight await. + // Keep the read pending until close() releases it. // oxlint-disable-next-line require-yield async function* sdkMessages(): AsyncGenerator { for (;;) { @@ -13430,9 +13751,7 @@ describe("ClaudeAdapterV2 query message stream", () => { ); yield* Effect.promise(() => readStarted.promise); - // Iterating query[Symbol.asyncIterator]() directly deadlocks here: - // the raw generator's return() queues behind the in-flight read and - // scope close never completes. + // The raw async generator's return() queues behind its pending read and deadlocks scope close. yield* Scope.close(scope, Exit.void); assert.isTrue(closed); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ec3ec5b05246..5240ee5ea762 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -331,8 +331,7 @@ function isClaudeSdkReplayMessage(frame: unknown): frame is SDKMessage { type === "system" || type === "stream_event" || type === "rate_limit_event" || - // Undeclared in the SDK types: queued/started/completed for each prompt - // that carries a uuid. + // The SDK types omit command_lifecycle for prompt uuids. type === "command_lifecycle" ); } @@ -654,10 +653,7 @@ function makeReplayQueryRunner( }, }); - // Prompt uuids are derived from ids that differ between the recording and - // a replay run, so a matched prompt offer maps the recorded uuid to the - // replayed one, and inbound frames echoing or acknowledging it are - // rewritten to match. + // Prompt uuids differ on replay; rewrite inbound echoes and acknowledgements to match. const promptUuidReplacements = new Map(); const replayedPromptUuid = (value: string): string => promptUuidReplacements.get(value) ?? value; const withReplayedPromptUuids = (frame: unknown): unknown => { @@ -994,12 +990,7 @@ function layerClaudeAgentSdkReplay( ); } -/** - * While a foreground Bash call runs, Claude Code writes its output to a task - * file the adapter tails. A replay has no Claude Code process, so it writes the - * file each recorded `task_started` names, holding that call's recorded output, - * under a scenario-owned `CLAUDE_CODE_TMPDIR`. - */ +/** Replays write recorded Bash output to task_started files under a scenario-owned CLAUDE_CODE_TMPDIR. */ const writeReplayTaskOutputFiles = Effect.fn("ClaudeReplay.writeTaskOutputFiles")(function* ( transcript: ClaudeAgentSdkReplayTranscript, ) { @@ -1204,8 +1195,7 @@ function sanitizeSdkMessageForReplay(input: { session_id: message.session_id, }; } - // Like init's slash_commands, the recording account's commands and skills - // are local configuration, not protocol. + // Recorded commands and skills are account configuration. if (message.type === "system" && message.subtype === "commands_changed") { return { ...message, commands: [] }; } @@ -1499,12 +1489,8 @@ async function recordMessagesUntilToolUse(input: { } } -// The SDK package may not ship a Claude Code executable for this platform, so -// recordings prefer the installed `claude` binary when it resolves on PATH to -// something the SDK can spawn directly, and otherwise leave the SDK's own -// executable discovery in place. A Windows launcher shim (`claude.cmd` and -// friends) is not directly spawnable, so it only counts when -// resolveClaudeSdkExecutablePath can follow it to a real package entry. +// The SDK may lack a platform executable. Prefer installed Claude only when directly spawnable, +// including Windows shims resolved to package entries. const resolveClaudeRecordingExecutablePath = Effect.fn("resolveClaudeRecordingExecutablePath")( function* (environment: NodeJS.ProcessEnv) { const resolveExecutable = yield* SpawnExecutableResolution; @@ -1653,9 +1639,7 @@ async function recordClaudeStreamingQuery(input: { frame: makeClaudePromptOfferFrame(message), }); promptQueue.offer(message); - // A task notification that lands during a turn queues a wake turn the - // CLI can run before this prompt's turn, so results are told apart by - // origin rather than by arrival order. + // Wake turns can precede the prompt turn; identify results by origin. let promptSettled = false; let wakes = 0; const expectedWakes = input.backgroundWakeCounts?.[index] ?? 0; @@ -1667,8 +1651,7 @@ async function recordClaudeStreamingQuery(input: { } } if (input.backgroundWakeCounts !== undefined && input.offerNextPromptImmediately !== true) { - // Every turn opens with system:init; frames from still-running - // subagents are left for the next prompt's recording. + // system:init opens each turn; leave ongoing subagent frames for the next recording. while (isSystemInitFrame(await iterator.peekWithin(CLAUDE_RECORDING_WAKE_QUIET_MS))) { await recordTurn(index + 1); } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bd4da5ee21f5..d1ee111f8513 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -58,6 +58,7 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { + CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, formatClaudeResumeCompactionQuestion, } from "@t3tools/shared/claudeCompaction"; @@ -66,6 +67,7 @@ import { ClaudeSettings, TrimmedNonEmptyString, defaultInstanceIdForDriver, + isCompactionCommandMessage, type ModelSelection, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, @@ -276,8 +278,7 @@ export function claudeProviderTurnTokenUsage( type ClaudeMessageUsage = Parameters[0]; -// message_delta usage is cumulative for its message. Fields it leaves null or -// absent keep their message_start value; explicit zeros replace it. +// message_delta usage is per-message cumulative; null or absent fields retain message_start values and zeros replace them. function mergeClaudeStreamUsage( current: ClaudeMessageUsage, delta: { @@ -299,10 +300,8 @@ function mergeClaudeStreamUsage( } export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROVIDER); const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); -// Entry extensions the Claude SDK runs through node instead of executing. const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; -// The SDK puts no deadline on control requests. The CLI answers -// mcp_set_servers once each server connected or hit its own 30 s timeout. +// The SDK has no control-request deadline; the CLI waits up to 30 s per MCP server. const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; // Claude can consume a subagent result before the launching turn ends, leaving // its notification with no native wake turn to answer the continuation. @@ -321,16 +320,9 @@ const CLAUDE_GROUP_WATCH_SCRIPT = [ ].join("\n"); /** - * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's - * `bun run start` wrapper dies and its `bun server.ts` keeps running, writing to a pipe nobody - * reads (#3089). Those stay in the CLI's group, so the rest of the group gets SIGKILL once the - * CLI exits for any reason. `onExit` then stops the CLI's agent scope, for daemons a tool - * detached from the group with setsid. - * - * That cleanup needs this server alive, and the CLI finishes its turn after its pipes close. A - * server that crashes or is killed would leave the CLI working next to the session it resumes - * (#23370), so a watcher in the group reads a pipe only this server holds and stops the group at - * its end-of-file. + * Kill the CLI's group on exit to stop MCP grandchildren; `onExit` stops its agent scope for + * setsid daemons. The CLI keeps working after its pipes close, so a watcher stops the group on + * EOF from a pipe only this server holds, including when the server crashes. */ export function spawnClaudeProcessGroup( options: ClaudeSpawnOptions, @@ -627,10 +619,7 @@ function closeClaudeQuery(queryRuntime: ClaudeQuery) { }); } -// Iterate the Query itself, not query[Symbol.asyncIterator]() (the raw -// sdkMessages generator). The raw generator's return() queues behind the -// in-flight read of the next CLI message and never settles while the CLI is -// idle, deadlocking stream interruption (and with it, session scope close). +// Iterate Query itself: the raw generator's return() queues behind a pending CLI read and deadlocks interruption. export function claudeQueryMessages(queryRuntime: ClaudeQuery): AsyncIterable { return { [Symbol.asyncIterator]: () => queryRuntime }; } @@ -921,14 +910,9 @@ export const layerQueryRunner: Layer.Layer< ), Stream.toAsyncIterable, ); - // The SDK would pass `mcpServers` as an inline `--mcp-config` argument, - // which every local user can read. They go over the CLI's stdin control - // channel instead, so header credentials stay out of its argv and out - // of the environment every command the agent runs inherits. + // Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments. const { mcpServers, ...queryOptions } = input.options; - // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`, - // which lets the agent scope wrapper go in front of the CLI. The SDK - // runs a script entry through node instead, so those launch unwrapped. + // The SDK can prepend a scope wrapper to native binaries; script entries run through node. const binaryPath = queryOptions.pathToClaudeCodeExecutable; const launch = binaryPath === undefined || @@ -975,8 +959,7 @@ export const layerQueryRunner: Layer.Layer< restore( Effect.gen(function* () { if (mcpServers !== undefined) { - // Error texts and the CLI's mcp_status response can echo the headers, so - // only server names are logged. + // Errors and mcp_status can echo headers; log only server names. const failed = yield* Effect.tryPromise(() => queryRuntime.setMcpServers(mcpServers), ).pipe( @@ -1252,11 +1235,7 @@ export const layerQueryRunner: Layer.Layer< type ClaudeFlagSettings = Parameters[0]; -/** - * Thinking is a launch flag (`--thinking`), so only a new process can change - * it. Clearing an explicit effort also needs one: the live reset goes to the - * model default, while a new process reads the user's settings files. - */ +/** Thinking requires a new process. Clearing effort also requires one to reload user settings instead of model defaults. */ function canApplyClaudeSelectionLive( live: CompiledClaudeModelSelection, next: CompiledClaudeModelSelection, @@ -1295,6 +1274,44 @@ export function claudeQueryEnvironment( return { ...environment, CLAUDE_CODE_PROMPT_CACHE_TTL: subagentPromptCacheTtl }; } +// Claude Code's auto tool search sends every MCP schema in full while they fit under a share of the +// context window, which T3's tools do on 1M models. auto:0 already means on and auto:100 off. +export function claudeToolSearchOverride(value: string | undefined): "true" | undefined { + if (value === undefined) return undefined; + if (value === "auto") return "true"; + if (!value.startsWith("auto:")) return undefined; + const percent = parseClaudeInteger(value.slice("auto:".length)); + // Claude Code treats an unparsable auto:N as auto, and clamps N to 0..100. + if (Number.isNaN(percent)) return "true"; + const clamped = Math.min(100, Math.max(0, percent)); + return clamped > 0 && clamped < 100 ? "true" : undefined; +} + +// Claude Code 2.1.310's integer parsing for auto:N. Scientific notation counts only when it is an +// integer, grouped digits (1_000, 1,000, or 1 000) are joined, and anything else uses parseInt. +const CLAUDE_SCIENTIFIC_INTEGER = /^[+-]?(\d+(\.\d*)?|\.\d+)[eE][+-]?\d+$/; +const CLAUDE_GROUPED_INTEGER = /^[+-]?\d{1,3}([_,\u00A0\u202F ])\d{3}(?:\1\d{3})*$/; +function parseClaudeInteger(text: string): number { + const value = text.trim(); + if (value.length <= 32 && CLAUDE_SCIENTIFIC_INTEGER.test(value)) { + const number = Number(value); + return Number.isInteger(number) ? number : Number.NaN; + } + if (value.length <= 32 && CLAUDE_GROUPED_INTEGER.test(value)) { + return Number.parseInt(value.replace(/[_,\u00A0\u202F ]/g, ""), 10); + } + return Number.parseInt(value, 10); +} + +// Claude Code defers MCP tools behind ToolSearch, so the agent only finds +// t3_background_wait when the system prompt says when to reach for it. +const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = ` + +### Background work you wait on + +Before you end your turn to wait for a background command or Monitor you started, such as tests, a build, or a CI wait, call \`t3_background_wait\` with its task ID. The thread then stays in the user's Working list until that task ends, instead of returning to them early. Do not mark a dev server, a watcher, or anything else you leave running. +`; + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -1316,6 +1333,8 @@ export function makeClaudeQueryOptions(input: { readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"]; readonly allowDangerouslySkipPermissions?: boolean; readonly appendSystemPromptFileText?: string; + // Flag settings, because a user's settings.json env beats the process env in Claude Code. + readonly toolSearchOverride?: "true"; }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const { @@ -1352,20 +1371,27 @@ export function makeClaudeQueryOptions(input: { : typeof input.sdkSettings === "object" && input.sdkSettings !== null ? ({ ...input.sdkSettings, ...selectionSettings } as ClaudeSdkSettings) : selectionSettings; - const compactedQuerySettings = + const compactQuerySettings = input.settings?.autoCompactWindow === undefined || input.settings.autoCompactWindow.length === 0 ? querySettings : ({ ...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}), autoCompactWindow: Number(input.settings.autoCompactWindow), } as ClaudeSdkSettings); - // A settings string is a path or JSON the caller owns, so it is left alone. + // A settings file path cannot take extra keys, so it goes through unchanged. const effectiveQuerySettings = - input.settingsEnv === undefined || typeof compactedQuerySettings === "string" - ? compactedQuerySettings + (input.settingsEnv === undefined && input.toolSearchOverride === undefined) || + typeof compactQuerySettings === "string" + ? compactQuerySettings : ({ - ...compactedQuerySettings, - env: { ...compactedQuerySettings?.env, ...input.settingsEnv }, + ...compactQuerySettings, + env: { + ...compactQuerySettings?.env, + ...input.settingsEnv, + ...(input.toolSearchOverride === undefined + ? {} + : { ENABLE_TOOL_SEARCH: input.toolSearchOverride }), + }, } as ClaudeSdkSettings); const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, @@ -1417,7 +1443,9 @@ export function makeClaudeQueryOptions(input: { preset: "claude_code" as const, append: [ buildRuntimeInstructions({ harness: "Claude Code" }) + - (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS), + (input.mcpServers === undefined + ? "" + : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS), ...launchAppendSystemPrompt, ].join("\n\n"), }, @@ -1471,8 +1499,7 @@ const readClaudeAppendSystemPromptFile = Effect.fnUntraced(function* (input: { export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; -// Must stay in sync with the Tool.Readonly annotations on OrchestratorToolkit; -// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit. +// Keep in sync with OrchestratorToolkit Tool.Readonly annotations; ClaudeAdapterV2.test.ts checks this. export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__orchestrator_capabilities", "mcp__t3-code__list_scheduled_tasks", @@ -1495,18 +1522,11 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__html_render", ]; -// Claude Code aborts an HTTP MCP call after 60 s ("The operation timed out.") -// unless the server config sets `timeout`. T3's wait tools (t3_thread_wait, -// delegate_task mode=wait) legitimately block for up to an hour -// (MAX_WAIT_TIMEOUT_MS in OrchestratorMcpService), so the budget sits just -// above that and the server's own wait timeout is what ends a long call. +// T3 waits can block for an hour; exceed Claude's default 60 s timeout. export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000; -// The SDK's `allowedTools` only pre-approves tool calls; availability is the separate `tools` -// option. Attaching the t3-code MCP server therefore always pre-approves its tools (headless -// modes like `dontAsk` deny anything that is not pre-approved), but read-only sandboxes -// pre-approve only the annotated read-only orchestrator tools so a read-only session cannot -// silently spawn threads or scheduled tasks. +// allowedTools pre-approves calls; tools controls availability. Pre-approve T3 tools for headless modes, +// limited to annotated read-only tools in read-only sandboxes. export function claudeMcpQueryOverrides(input: { readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; @@ -1563,10 +1583,7 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content" return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); } -// Claude 5.1 and later return the text written ahead of a tool call as a -// thinking block, at most one per call, instead of a text block. Summarized -// display makes it look like reasoning, so only the second of two thinking -// blocks right before a tool call is treated as that text. +// Claude 5.1+ emits at most one pre-tool text block as thinking; treat only a second consecutive thinking block as text. function claudeWritesUpdatesAsThinking(model: string): boolean { const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model); if (match === null) return false; @@ -1574,9 +1591,7 @@ function claudeWritesUpdatesAsThinking(model: string): boolean { return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1); } -// In SDK mode Claude reports `/goal` only through the transcript (its -// `active_goal` event is remote-only): synthetic command output names the -// goal, and each unmet evaluator check returns as Stop hook feedback. +// In SDK mode, /goal appears only in transcripts: command output names it and Stop hooks report unmet checks. const CLAUDE_GOAL_SET_PREFIX = "Goal set: "; const CLAUDE_GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \((?:not yet evaluated|(\d+) turns?)\)/u; @@ -1855,11 +1870,8 @@ export function makeClaudeUserMessage(input: { readonly skills?: ReadonlyMap; readonly uuid?: SDKUserMessage["uuid"]; }): SDKUserMessage { - // Claude Code expands a skill only from the LAST text block, and only when - // `/name` is its first character. A `$skill` chip anywhere in the prompt - // therefore becomes [whole request, "/name trailing text"] so the CLI runs - // it natively and the model still reads the full request. See - // ClaudeSkillDispatch. + // Claude expands a skill only when `/name` starts the last text block. + // Keep the whole request before that block; see ClaudeSkillDispatch. const dispatch = input.skills === undefined ? undefined : planClaudeSkillDispatch(input.text, input.skills); return { @@ -1958,8 +1970,7 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: }); } - // Images go before the command block: a text block after them still - // expands, a command block followed by an image does not. + // Images must precede the command block for skill expansion. if (dispatch) { content.push({ type: "text", text: dispatch.commandText }); } else if (textWithAttachmentPaths.length > 0) { @@ -2141,8 +2152,7 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy) ? readOnlyTools : undefined; - // acceptEdits approves edits before the callback runs; everything else it - // leaves to the callback, which must ask rather than allow. + // acceptEdits approves edits before the callback; the callback must ask for everything else. const installPermissionCallback = downgraded || (runtimePolicy.approvalPolicy === undefined @@ -2198,6 +2208,14 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( ), ), ); +const decodeClaudeEnvSettings = Schema.decodeUnknownEffect( + fromLenientJson( + Schema.StructWithRest( + Schema.Struct({ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)) }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), +); function claudeBypassAvailability( settings: typeof ClaudeBypassSettings.Type, @@ -2226,9 +2244,7 @@ function claudeRuntimeQueryPolicyKey(policy: ClaudeRuntimeQueryPolicy): string { }); } -// Live-query reuse must key on the effective allowlist (including the -// MCP-derived pre-approvals), not just the runtime policy: policies that -// share a policy key can still differ in MCP pre-approvals. +// Live-query reuse must include MCP pre-approvals, which can differ for the same policy key. export function claudeEffectiveQueryPolicyKey( queryPolicy: ClaudeRuntimeQueryPolicy, mcpOverrides: { @@ -2385,8 +2401,7 @@ function commandInputFromClaudeTool(toolName: string, input: ClaudeNativeToolInp ); } -// Subagents project through the normal subagent lifecycle and must not be double-counted when -// background_tasks_changed includes them. +// background_tasks_changed can include subagents already counted by their lifecycle. const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap< string, Exclude @@ -2408,8 +2423,7 @@ function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): function claudePendingBackgroundTask(input: { readonly taskId: string; readonly taskType: string | null; - // Claude runs a Monitor as a local_bash task, so only the Monitor tool call - // that started it tells it apart from a background Bash command. + // Only the launching Monitor tool distinguishes a local_bash Monitor from background Bash. readonly startedByMonitor: boolean; readonly description: string | undefined; // Undeclared SDK field: the subagent whose own background work this is. @@ -2461,8 +2475,11 @@ function isClaudeRootTurnFrame(message: SDKMessage): boolean { function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, + awaitedTaskIds: ReadonlySet, ): ReadonlyArray { - return Array.from(roster.values()); + return Array.from(roster.values(), (task) => + awaitedTaskIds.has(task.taskId) ? { ...task, awaited: true as const } : task, + ); } function parseClaudeBackgroundTaskEntry( @@ -2993,10 +3010,7 @@ const awaitClaudeUserInputAnswers = Effect.fn("awaitClaudeUserInputAnswers")(fun return yield* Effect.raceFirst(Deferred.await(answers), cancellation); }); -/** - * "[ede_diagnostic] ..." entries are CLI-internal telemetry (the CLI hides them from its own UI - * too), so they must never become the error banner (#5557). - */ +/** Exclude CLI-internal "[ede_diagnostic] ..." telemetry from error banners. */ function resultUserFacingError(result: SDKResultMessage): string | undefined { const errors = "errors" in result && Array.isArray(result.errors) ? result.errors : []; if (result.subtype === "success" && !result.is_error) { @@ -3028,8 +3042,7 @@ function terminalResultError( case "turn_setup_failed": return "Claude could not start the turn."; case "blocking_limit": - // The CLI also ends a turn with blocking_limit once the context window - // is full ("Prompt is too long"). Only rate-limit evidence sets the hint. + // blocking_limit also means context exhaustion; only rate-limit evidence sets the hint. return failureHint ?? CONTEXT_WINDOW_FULL_ERROR; case "rapid_refill_breaker": return "Claude stopped: the context refilled too quickly after compaction."; @@ -3055,8 +3068,7 @@ function terminalStatusFromResult( OrchestrationV2ProviderTurn["status"], "completed" | "interrupted" | "failed" | "cancelled" > { - // The CLI can label an abort as success with is_error=false. Its explicit - // terminal reason takes precedence over that envelope. + // Explicit abort reasons override success envelopes with is_error=false. if ( message.terminal_reason === "aborted_tools" || message.terminal_reason === "aborted_streaming" @@ -3064,8 +3076,7 @@ function terminalStatusFromResult( return "interrupted"; } if (message.subtype === "success") { - // The SDK reports API-level failures (401 auth, 529 overloaded, …) as - // subtype "success" with is_error set; the turn produced no real work. + // The SDK can report API failures as subtype "success" with is_error set. return isOverloadedResult(message) || message.api_error_status === 429 || terminalResultError(message.terminal_reason, failureHint) !== undefined || @@ -3142,9 +3153,7 @@ function claudePromptLifecycle( return typeof uuid === "string" && typeof state === "string" ? { uuid, state } : null; } -// An echo names the prompts its turn consumed. Without one, only a process -// known to echo proves the turn foreign by its non-human origin; on a CLI that -// never echoes, that result can be the prompt turn's only result. +// Without an echo, non-human origin proves a foreign turn only on CLIs known to echo prompts. function isClaudeResultForOtherTurn(input: { readonly message: SDKResultMessage; readonly promptUuid: string; @@ -3443,11 +3452,9 @@ interface ActiveClaudeTurnContext { { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } >; }; - // Usage of the latest root request, merged from its stream events. Its - // assistant snapshots repeat message_start usage, which some proxies send - // as zeros, so they only fill in input the stream has not reported. A - // compaction makes the usage stale (null) without forgetting the request. - // `requestedAt` is when a streamed request started; its prompt cache lifetime starts then. + // Snapshots repeat message_start input, sometimes as zeros, so only fill gaps in streamed usage. + // Compaction clears usage without forgetting the root request. + // `requestedAt` starts the request's prompt cache lifetime. streamUsage: { readonly messageId: string; requestedAt?: DateTime.Utc; @@ -3468,8 +3475,7 @@ interface ActiveClaudeTurnContext { // Set while this turn's latest compaction status reports a failure, with // Claude's error text when it sent one. compactionFailure: { readonly message: string | undefined } | null; - // Usage of the results that steers ended, oldest first. The final result's - // usage covers only its own segment, so the turn bills these as well. + // Steers end segments whose usage is excluded from the final result; bill them too, oldest first. readonly steeredSegmentUsage: Array; readonly subagentsByTaskId: Map; readonly subagentsByToolUseId: Map; @@ -3519,9 +3525,7 @@ interface ClaudeLiveQueryContext { selection: CompiledClaudeModelSelection; readonly closed: Deferred.Deferred; lastTurn: ActiveClaudeTurnContext | null; - // Whether this CLI process echoes a prompt's uuid on the first frame of the turn answering it - // ("early") or only on its result. "acknowledged": the CLI confirmed it took a prompt's uuid - // before any echo, so it echoes, but a resume's own turns can still run ahead of that prompt. + // "early" echoes on the first frame; "acknowledged" confirms uuid acceptance, but resume turns can still precede the prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; // The CLI may fall back from the requested mode on its first init frame. openedPermissionMode: PermissionMode; @@ -3545,7 +3549,6 @@ interface ActiveClaudeToolCall { readonly presentation?: ClaudeToolPresentation; } -/** Where a subagent's conversation items land: its child thread and root node. */ interface ClaudeSubagentProjectionTarget { readonly childThreadId: ThreadId; readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; @@ -3553,13 +3556,8 @@ interface ClaudeSubagentProjectionTarget { readonly toolCalls: Map; } -/** - * Whether a transcript line ends the assistant's turn. Claude Code writes each - * block of a message as its own line, `stop_reason: null` on all but the last, - * and text before a tool call (`tool_use`) is not the reply. A line without the - * field (no CLI writes one) keeps the turn open: waiting costs at most the flush - * window, while ending the turn early can lose the reply. - */ +/** Claude writes blocks separately with stop_reason null until the last. + * Missing stop_reason keeps the turn open; pre-tool text is not the reply. */ function isClaudeTranscriptTurnEnd(entry: unknown): boolean { if (field(entry, "type") !== "assistant") return false; const stopReason = field(field(entry, "message"), "stop_reason"); @@ -4008,6 +4006,73 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); + const readToolSearchSetting = (filePath: string) => + fileSystem.readFileString(filePath).pipe( + Effect.flatMap(decodeClaudeEnvSettings), + Effect.map((settings) => { + const value = settings.env?.ENABLE_TOOL_SEARCH; + return typeof value === "string" ? value : undefined; + }), + Effect.orElseSucceed(() => undefined), + ); + // A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to + //
/.git. A bare repository has no main checkout, so its worktree keeps its own file. + const mainCheckoutRoot = (checkout: string) => + fileSystem.readFileString(path.join(checkout, ".git")).pipe( + Effect.flatMap((pointer) => { + const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (gitDir === undefined) return Effect.succeed(checkout); + const worktreeGitDir = path.resolve(checkout, gitDir); + return fileSystem.readFileString(path.join(worktreeGitDir, "commondir")).pipe( + Effect.map((commonDir) => { + const common = path.resolve(worktreeGitDir, commonDir.trim()); + return path.basename(common) === ".git" ? path.dirname(common) : checkout; + }), + ); + }), + Effect.orElseSucceed(() => checkout), + ); + // Claude Code keeps .claude/settings.local.json at the repository root, or in cwd outside a + // repository, on Windows, or when the root is home. Its owner check is skipped: missing it only + // leaves tool search on, Claude Code's default. + const localSettingsDirectory = Effect.fn("ClaudeAdapterV2.localSettingsDirectory")(function* ( + cwd: string, + ) { + if ((yield* HostProcess.Platform) === "win32") return cwd; + for (let directory = cwd; ; directory = path.dirname(directory)) { + const isCheckout = yield* fileSystem + .exists(path.join(directory, ".git")) + .pipe(Effect.orElseSucceed(() => false)); + if (isCheckout) { + const root = yield* mainCheckoutRoot(directory); + return root === (yield* HostProcess.HomeDirectory) ? cwd : root; + } + if (path.dirname(directory) === directory) return cwd; + } + }); + // Settings env beats the process env, local over project over user, and a local file an older + // Claude Code left in cwd applies under the root one. resolveSettings reads local from cwd only. + const resolveToolSearchOverride = Effect.fn("ClaudeAdapterV2.resolveToolSearchOverride")( + function* (cwd: string | null) { + const localDirectory = cwd === null ? null : yield* localSettingsDirectory(cwd); + const settingsFiles = [ + ...(cwd === null || localDirectory === null + ? [] + : [ + path.join(localDirectory, ".claude", "settings.local.json"), + ...(localDirectory === cwd ? [] : [path.join(cwd, ".claude", "settings.local.json")]), + path.join(cwd, ".claude", "settings.json"), + ]), + path.join(homePath, "settings.json"), + ]; + for (const settingsFile of settingsFiles) { + const value = yield* readToolSearchSetting(settingsFile); + if (value !== undefined) return claudeToolSearchOverride(value); + } + return claudeToolSearchOverride(adapterOptions.environment.ENABLE_TOOL_SEARCH); + }, + ); + const userInvocableSkills = (cwd: string | null) => discoverClaudeSkills( adapterOptions.settings, @@ -4044,7 +4109,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const events = yield* Queue.unbounded(); const activeTurn = yield* Ref.make(null); - // Foreground Bash calls whose output file is being tailed, by tool_use_id. const taskOutputTails = new Map< string, { readonly fiber: Fiber.Fiber; readonly stop: Deferred.Deferred } @@ -4062,8 +4126,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); const sdkMessageLock = yield* Semaphore.make(1); - // Survives turn settle: members mostly finish after the launching - // turn has ended, and each transition must be emitted exactly once. + // Members can finish after the launching turn; retain state to emit each transition once. const workflowMemberStates = yield* Ref.make( new Map< string, @@ -4072,14 +4135,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly attempt: number; readonly transcriptReadAttempts: number; readonly finalTranscriptRead: boolean; - // Where the next read of the member's transcript starts; every - // line before it is already projected into the member's thread. readonly agentId: string | undefined; readonly transcriptCursor: WorkflowAgentTranscriptCursor; readonly nextItemOrdinal: number; readonly toolCalls: Map; readonly textShown: boolean; - // The transcript reached the end of the member's last turn. readonly turnEnded: boolean; readonly resultShown: boolean; readonly result: string | undefined; @@ -4137,9 +4197,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingRuntimeRequests = yield* Ref.make( new Map(), ); - // Claude can settle a turn while a local_bash background task keeps running; the CLI - // later re-invokes the model (a "wake turn") on the same query stream with no active - // provider turn. + // Background local_bash tasks can trigger wake turns on the query stream with no active provider turn. const lastTurnRouteByNativeThread = yield* Ref.make( new Map< string, @@ -4149,6 +4207,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingBackgroundTasksByNativeThread = yield* Ref.make( new Map>(), ); + // Survives roster REPLACE levels, which rebuild every entry without the agent's mark. + const awaitedBackgroundTasksByNativeThread = yield* Ref.make( + new Map>(), + ); // Wake eligibility is separate from the Waiting roster. It survives empty // background_tasks_changed levels (SDK: empty level can precede task_notification) and // is consumed when the first idle notification is buffered/offered so duplicates cannot @@ -4164,15 +4226,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); const goalsByNativeThread = new Map(); const goalCheckedTurns = new Set(); - // Subagent registry that survives turn settle: a background subagent - // (Agent with run_in_background) can complete after the root turn - // ended, and its task_notification must both count as wake evidence - // and hydrate the original subagent node instead of being dropped. + // Background subagents can finish after root settlement; retain their registry for wake evidence and hydration. const sessionSubagentsByTaskId = yield* Ref.make(new Map()); const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map()); - // Requests that move a running subagent to the background before a - // steer, by the tool call that started its run. Claude may never - // answer one, so a later steer waits on it rather than asking again. + // Claude may never answer a background move; later steers must wait on the same request. const backgroundMovesByToolUseId = new Map>(); const subagentToolCalls = new Map(); const pendingSubagentLaunchesByToolUseId = new Map(); @@ -4181,12 +4238,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( string, Deferred.Deferred >(); - // An idle outcome stays buffered for the parent's wake, but must not - // settle the child again after that child resumes. + // Buffered idle outcomes must not settle a resumed child again. const appliedSubagentOutcomes = new WeakSet(); - // Subagent frames can precede the task_started that registers their - // subagent (the same race rememberPendingClaudeSubagentLaunch covers). - // They wait here and replay once task_started registers the owner. + // Subagent frames can precede the task_started that registers their owner. const pendingSubagentFramesByToolUseId = yield* Ref.make( new Map>(), ); @@ -4196,10 +4250,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( { readonly messages: ReadonlyArray; readonly detail: string | null } >(), ); - // Claude's wake result carries no task id, and its wake can run after a prompt the user - // queued meanwhile, so reports survive one user turn and then expire: a notification - // Claude folded into its own turn cannot name a later wake. One Ref holds both, so a - // report cannot be stamped with a turn that has already been superseded. + // Wake results lack task ids and can follow queued prompts; retain reports for one user turn. + // Keep report stamps atomic with turn changes. const wakeReportsByNativeThread = yield* Ref.make<{ readonly userTurns: ReadonlyMap; readonly reports: ReadonlyMap< @@ -4208,8 +4260,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( >; }>({ userTurns: new Map(), reports: new Map() }); const backgroundedSubagentTaskIds = yield* Ref.make>(new Set()); - // Subagents another subagent started (spawn_depth above 1). Claude - // reports their end to the owning subagent, so it never wakes the root. + // Nested subagent completions notify their owner and never wake the root. const nestedSubagentTaskIds = yield* Ref.make>(new Set()); const isNestedSubagentTask = (taskId: string) => Ref.get(nestedSubagentTaskIds).pipe(Effect.map((ids) => ids.has(taskId))); @@ -4250,8 +4301,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const clearWakeReports = (nativeThreadId: string) => takeWakeReports(nativeThreadId).pipe(Effect.asVoid); - // An empty roster level can land before the task's notification, and - // the wake still needs to name the task. + // Empty roster levels can precede notifications; retain tasks for wake attribution. const lastKnownOpaqueTasks = yield* Ref.make( new Map(), ); @@ -4348,8 +4398,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); - // Reports one root request's usage as the current context. Usage - // without input is a placeholder, never a measurement. + // Usage without input is a placeholder, not a context measurement. const emitContextUsage = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, usage: ClaudeMessageUsage, @@ -4390,8 +4439,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // Claude emits retry progress but no recovered frame; the next - // assistant message is the first reliable evidence of recovery. + // Claude has no recovered frame; the next assistant message proves recovery. const completeProviderRetry = Effect.fn("ClaudeAdapterV2.completeProviderRetry")(function* ( context: ActiveClaudeTurnContext, updatedAt: DateTime.Utc, @@ -4582,12 +4630,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(pendingBackgroundTasksByNativeThread), input.nativeThreadId, ); + const awaitedTaskIds = taskIdSetForNativeThread( + yield* Ref.get(awaitedBackgroundTasksByNativeThread), + input.nativeThreadId, + ); const now = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { ...input.providerThread, providerSessionId: session.id, ...(input.status === undefined ? {} : { status: input.status }), - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster, awaitedTaskIds), ...(goalsByNativeThread.has(input.nativeThreadId) ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null } : {}), @@ -4669,6 +4721,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( tasks.map((task) => task.taskId), ); } + const listed = new Set(tasks.map((task) => task.taskId)); + yield* Ref.update(awaitedBackgroundTasksByNativeThread, (current) => { + const awaited = current.get(nativeThreadId); + if (awaited === undefined) return current; + const kept = new Set([...awaited].filter((taskId) => listed.has(taskId))); + if (kept.size === awaited.size) return current; + const updated = new Map(current); + if (kept.size === 0) updated.delete(nativeThreadId); + else updated.set(nativeThreadId, kept); + return updated; + }); }); const upsertPendingBackgroundTask = @@ -4701,7 +4764,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.set(nativeThreadId, nextRoster); } return [true, updated] as const; - }); + }).pipe( + Effect.tap(() => + clearTaskIdFromNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ), + ), + ); const clearPendingBackgroundTasksForNativeThread = /* Provider events include streaming deltas; avoid a span for each update. */ @@ -4714,6 +4785,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.delete(nativeThreadId); return updated; }); + yield* clearNativeThreadTaskIdSet(awaitedBackgroundTasksByNativeThread, nativeThreadId); yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId); }); @@ -4739,8 +4811,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // Process-scoped level: SDK emits nothing at CLI start, so both the Waiting roster and - // wake eligibility reset when a live query opens or is replaced for this native thread. + // The SDK emits no initial roster; reset roster and wake eligibility when opening a live query. const resetBackgroundTaskStateForNativeThreadProcess = Effect.fnUntraced(function* ( nativeThreadId: string, options?: { @@ -4858,7 +4929,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const buildToolCallArtifacts = (input: { - // Only a call in a run (runId set) needs its provider turn. readonly context: ActiveClaudeTurnContext | null; readonly nativeItemId: string; readonly toolName: string; @@ -5055,9 +5125,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // The resume's task_started names only the SendMessage call, while the resumed run's - // frames carry the launch call as parent_tool_use_id, which only the CLI's session - // storage still records. + // Resume frames reference the launch call, but task_started names SendMessage; recover the mapping from CLI storage. const recoverResumedClaudeSubagent = Effect.fnUntraced(function* (resume: { readonly context: ActiveClaudeTurnContext; readonly nativeThreadId: string; @@ -5267,8 +5335,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (input.coordinator.task.status === "running" ? (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) < 3 : restarted || previous?.finalTranscriptRead !== true); - // The CLI forwards nothing a member says, so every frame while it - // runs reads what its transcript gained since the last one. const readLiveTranscript = status === "running" && transcriptDir !== undefined && agentId !== undefined; if (!changed && !readTranscript && !readLiveTranscript) continue; @@ -5403,8 +5469,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolCalls.clear(); }); if (restarted) yield* endOpenToolCalls(); - // A retry may rewrite the transcript in place, past the old cursor; - // re-reading from 0 re-emits only items already shown. + // Retries can rewrite transcripts past the cursor; rereading from zero repeats only already shown items. let transcriptCursor = previous === undefined || restarted || previous.agentId !== agentId ? initialWorkflowAgentTranscriptCursor @@ -5414,17 +5479,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( let turnEnded = !restarted && previous?.turnEnded === true; let transcriptRead = false; let lastReadAt = now; - // Text before a tool call is not the reply, so wait for the turn's end. + // Pre-tool text is not the reply; wait for turn end. const awaitingReply = () => settled && transcriptRead && !turnEnded && !resultShown; - // Milliseconds until a read started at `at` sees every line the - // member wrote before it settled. const flushWindowLeft = (at: DateTime.Utc) => completedAt === null ? 0 : WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS - (DateTime.toEpochMillis(at) - DateTime.toEpochMillis(completedAt)); - // Each read is capped, so keep reading until the transcript has no - // more complete lines; a settled member's answer may be past the cap. + // Drain capped reads; settled replies can lie beyond the cap. let transcriptDrained = true; if ( (readTranscript || readLiveTranscript) && @@ -5445,7 +5507,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), }), }).pipe(Effect.option); - // False once the coordinator was replaced mid-read. const drain = Effect.fnUntraced(function* () { lastReadAt = yield* DateTime.now; transcriptDrained = false; @@ -5459,7 +5520,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( transcriptCursor = read.value.cursor; for (const line of read.value.lines) { if (yield* projectSubagentTranscriptLine(target, line)) textShown = true; - // A later line (a retry, or a resumed member) reopens the turn. turnEnded = isClaudeTranscriptTurnEnd(line); } // A line longer than the cap can't advance; stop rather than spin. @@ -5468,8 +5528,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return true; }); if (!(yield* drain())) return; - // The coordinator sends nothing after its last frame, so wait out - // the flush window here rather than settle on the excerpt. + // The coordinator sends no more frames; wait out the transcript flush before using the excerpt. const waitLeft = flushWindowLeft(lastReadAt); if ( input.coordinator.task.status !== "running" && @@ -5482,11 +5541,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } if (settled) { - // As for an ordinary subagent, a call still open once the member - // settles will never get its result. + // Open calls receive no results after their member settles. yield* endOpenToolCalls(); - // Only the progress excerpt is left when the transcript says - // nothing, read after its last writes had time to land. if ( !textShown && !resultShown && @@ -5616,11 +5672,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent.task.runId !== input.onlyIfRunningInRun) ) return; - // Status is monotone with one exception: task_started for a known - // task id is an authoritative CLI lifecycle event (SendMessage to a - // completed subagent resumes it and re-emits task_started with the - // same id), so it may re-open a terminal entry. A late or - // out-of-order task_progress must not. + // Only task_started may reopen a terminal task id on resume; late task_progress must not. const isReopen = input.reopen === true && existingSubagent !== undefined && @@ -5634,9 +5686,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return; } - // A task_started under a tool call other than the current run's is - // a resume: SendMessage re-emits task_started for the same task id - // under its own tool_use_id, with the sent message as the prompt. + // Resume task_started uses the SendMessage tool_use_id and sent message as its prompt. const resumeToolUseId = input.reopen === true && existingSubagent !== undefined && @@ -5778,13 +5828,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( lastAssistantMessageId: input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null), } satisfies ActiveClaudeSubagent; - // The same terminal protection, applied atomically: a concurrent - // fiber (live stream vs continuation drain) may have terminalized - // the registry entry after this update's lookup read it. A resume - // re-open (task_started) bypasses it only when the registered entry - // is still the terminal generation the lookup resolved; if a - // concurrent fiber installed a newer terminal entry meanwhile, the - // re-open must not clobber its result. + // Ingestion can terminalize after lookup; reopen only that generation to preserve newer results. const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { const registered = current.get(input.taskId); if ( @@ -5885,8 +5929,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, }); } - // Ahead of the child thread's records: the run a resume attributes - // the subagent to adopts its child thread from this event. + // Emit before child records so the resumed run can adopt the child thread. yield* emitProviderEvent({ type: "subagent.updated", driver: CLAUDE_PROVIDER, @@ -5929,9 +5972,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( rootNodeId: childRootNodeId, role: "user", text: task.prompt, - // A workflow's prompt is its script; clients open the thread with - // the workflow's plan instead and keep the script collapsed. A - // brief says it is not one, so its text is never taken for one. + // Workflow prompts are collapsed scripts; clients open with the plan. A brief must not be mistaken for a script. ...(existingSubagent === undefined ? { workflowScript: task.workflow !== undefined } : {}), @@ -6007,14 +6048,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - // A backgrounded subagent (Agent with run_in_background) keeps - // streaming task_progress while the root turn is idle. Those frames - // carry only a point-in-time description, with nothing to usefully - // replay later, so unlike other frames they are not parked in the - // wake buffer: without this, the card shows whatever progress it had - // when the root settled for as long as the subagent keeps running. - // Hydrate the session registry that survives turn settle directly, - // reusing the turn attribution captured when the card was created. + // A backgrounded subagent keeps streaming task_progress while the root is idle. Those frames + // are point-in-time and not worth replaying, so skip the wake buffer or the card goes stale. + // Hydrate the settle-surviving session registry directly, reusing the card's turn attribution. const applyClaudeSubagentProgressWithoutActiveTurn = Effect.fnUntraced(function* (input: { readonly message: Extract; }) { @@ -6237,8 +6273,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }) { const existing = findToolCall(input.context, input.nativeItemId); if (existing !== undefined) { - // The permission callback can start a call before its assistant - // frame arrives with the tool's display name and icon. + // Permission callbacks can start calls before assistant frames provide their display name and icon. if (input.presentation === undefined || existing.presentation !== undefined) { return existing; } @@ -6455,11 +6490,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - /** - * Projects one transcript line of a subagent whose messages the CLI - * does not forward (a workflow member) the way a forwarded frame with - * its parent_tool_use_id is projected. Returns whether it carried text. - */ const projectSubagentTranscriptLine = Effect.fnUntraced(function* ( target: ClaudeSubagentProjectionTarget, line: unknown, @@ -6740,6 +6770,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); if (input.context.compaction?.status === "running") { + const reason = input.result?.subtype === "success" ? input.result.result.trim() : ""; yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -6750,6 +6781,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.status === "completed" || input.status === "failed" ? "Context compaction failed" : "Context compaction stopped", + ...(reason === "" ? {} : { summary: reason }), completedAt: input.completedAt, updatedAt: input.completedAt, }, @@ -6766,8 +6798,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) continue; if (liveQuery !== null && !liveQuery.stopping) { - // Best effort and off the terminal path: an unanswered stop must - // not keep the turn from ending. + // An unanswered stop must not delay turn settlement. yield* liveQuery.query .stopTask(taskId) .pipe(Effect.timeout("15 seconds"), Effect.ignore, Effect.forkIn(sessionScope)); @@ -6928,8 +6959,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), }, }), - // Surface this native thread's roster before the root turn terminals so - // writeFinalRunEvents preserves it. + // Emit the roster before root settlement so writeFinalRunEvents preserves it. Effect.gen(function* () { const nativeThreadId = input.context.input.providerThread.nativeThreadRef?.nativeId ?? null; @@ -6953,6 +6983,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(pendingBackgroundTasksByNativeThread), nativeThreadId, ); + const awaitedTaskIds = + nativeThreadId === null + ? new Set() + : taskIdSetForNativeThread( + yield* Ref.get(awaitedBackgroundTasksByNativeThread), + nativeThreadId, + ); const clearConversationHead = input.status === "completed" && input.context.input.providerThread.nativeConversationHeadRef !== null; @@ -6990,7 +7027,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context.input.providerThread.firstRunOrdinal ?? input.context.input.runOrdinal, lastRunOrdinal: input.context.input.runOrdinal, - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster( + roster, + awaitedTaskIds, + ), status: input.status === "completed" ? "active" : "idle", updatedAt: input.completedAt, }; @@ -7221,9 +7261,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( entry.subtype === "task_started" && entry.task_id === message.task_id, )); - // A subagent launched while the root is idle (inside a native wake - // turn) must reach the continuation drain with its task_started, or - // its frames have no registered owner and are held indefinitely. + // Idle-wake task_started must reach the continuation drain before child frames can find their owner. const isNewSubagentTaskStarted = message.type === "system" && message.subtype === "task_started" && @@ -7317,10 +7355,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.task_id, ); } - // Buffer it for replay, but do not open an opaque-task continuation until the turn's - // `init`, or native user, assistant, or result output, proves that Claude actually - // began the wake turn. Only root frames prove it: a background subagent keeps - // streaming its own frames while the root is idle. + // Open continuations only after root init, user, assistant, or result frames prove a wake began. + // Background child frames can stream while the root is idle. const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); const hasBufferedNotification = buffered?.messages.some( @@ -7377,6 +7413,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); + // Untraced like the roster message handling: it runs for every streamed roster change. + const emitRosterUpdate = Effect.fnUntraced(function* ( + nativeThreadId: string, + activeContext: ActiveClaudeTurnContext | null, + ) { + const baseThread = + activeContext?.input.providerThread ?? + (yield* Ref.get(lastProviderThreadByNativeThread)).get(nativeThreadId); + if (baseThread === undefined) { + return; + } + const status = + activeContext === null + ? ("idle" as const) + : baseThread.status === "idle" + ? ("active" as const) + : baseThread.status; + yield* emitProviderThreadRoster({ nativeThreadId, providerThread: baseThread, status }); + }); + const applyBackgroundTaskRosterMessage = Effect.fnUntraced(function* (input: { readonly nativeThreadId: string; readonly message: SDKMessage; @@ -7450,24 +7506,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; } - const baseThread = - input.activeContext?.input.providerThread ?? - (yield* Ref.get(lastProviderThreadByNativeThread)).get(input.nativeThreadId); - if (baseThread === undefined) { - return true; - } - - const status = - input.activeContext === null - ? ("idle" as const) - : baseThread.status === "idle" - ? ("active" as const) - : baseThread.status; - yield* emitProviderThreadRoster({ - nativeThreadId: input.nativeThreadId, - providerThread: baseThread, - status, - }); + yield* emitRosterUpdate(input.nativeThreadId, input.activeContext); return true; }); @@ -7546,10 +7585,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const message = input.message; - // Before any routing: a Monitor started during an idle wake turn - // reports its task before the drain replays the tool call. + // Track before routing: idle-wake Monitors report tasks before replaying their tool calls. yield* trackClaudeMonitorCalls(message); - // Before routing too: the wake gate reads it while the root is idle. + // Update before routing because the idle wake gate reads this. if ( message.type === "system" && message.subtype === "task_started" && @@ -7651,8 +7689,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { yield* applyWorkflowProgressWithoutTurn(message); - // task_notification must buffer wake evidence while still tracked - // on the roster; clearing first would drop the wake pin. + // Buffer wake evidence before clearing the task from the roster. if (message.type === "system" && message.subtype === "task_notification") { yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message }); yield* applyBackgroundTaskRosterMessage({ @@ -7660,7 +7697,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message, activeContext: null, }); - // Record wake evidence first, while the child is still running. const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id); const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId); const hasBufferedStart = @@ -7693,11 +7729,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); } } else if (message.type === "system" && message.subtype === "task_progress") { - // A backgrounded subagent keeps streaming progress while the - // root is idle. task_progress is not wake evidence (it never - // proves the CLI re-engaged) and carries nothing worth - // replaying later, so it updates the card now instead of - // going through bufferWakeMessage. + // task_progress from a backgrounded subagent is not wake evidence and has nothing worth + // replaying, so it updates the card now instead of going through bufferWakeMessage. yield* applyClaudeSubagentProgressWithoutActiveTurn({ message }); } else { yield* applyBackgroundTaskRosterMessage({ @@ -7774,8 +7807,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( !context.reasoning.snapshots.has(message.uuid) ) { context.reasoning.snapshots.add(message.uuid); - // The SDK emits one assistant snapshot per completed content block. - // Count thinking blocks separately so snapshots share the streamed ID. + // The SDK snapshots each completed block; count thinking blocks separately to preserve streamed ids. for (const block of message.message.content) { if (block.type !== "thinking") { const update = context.reasoning.lastThinking; @@ -7873,6 +7905,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : message.compact_result === "failed" ? "Context compaction failed" : "Context compaction stopped", + ...(message.compact_result === "failed" && message.compact_error + ? { summary: message.compact_error } + : {}), completedAt: now, updatedAt: now, }; @@ -8073,8 +8108,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (message.type === "assistant" && input.replayed !== true) { const now = yield* DateTime.now; yield* completeProviderRetry(context, now); - // `` frames (usage limits, sign-in errors, /goal status) - // are generated by the CLI and report zero usage, not the context. + // CLI-generated frames report zero usage, not context usage. if ( message.parent_tool_use_id === null && message.message.usage !== undefined && @@ -8083,7 +8117,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const snapshotUsage = message.message.usage; const stream = context.streamUsage; if (stream === null || stream.messageId !== message.message.id) { - // Remember the request so a compaction can mark its later snapshots stale. context.streamUsage = { messageId: message.message.id, usage: snapshotUsage }; yield* emitContextUsage(context, snapshotUsage); } else if ( @@ -8101,8 +8134,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const parentToolUseId = message.parent_tool_use_id; const snapshotModel = typeof message.message.model === "string" ? message.message.model.trim() : ""; - // Claude's own notices (a usage limit, an API error) carry - // "", which says nothing about the subagent's model. + // "" identifies CLI notices, not the subagent model. const model = snapshotModel.length === 0 || snapshotModel === "" ? undefined @@ -8223,8 +8255,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( launch?.ownerToolUseId === undefined ? undefined : yield* resolveSubagentByToolUseId(context, launch.ownerToolUseId); - // With no model of its own, a nested subagent runs on its - // owner's (the SDK's default for a subagent's Agent call). + // Nested subagents default to their owner's model. const model = launch?.model ?? owner?.task.model ?? undefined; if ( message.is_backgrounded === true && @@ -8444,18 +8475,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( status: "running", }); } - // A resume task_started reuses the resuming tool call's - // tool_use_id (e.g. SendMessage), whose tool_result only - // acknowledges delivery. Only the Agent launch's tool_result may - // terminalize the subagent, and Agent tool_uses never enter - // toolCalls (they project as subagent rows instead). + // Resume tool results only acknowledge delivery; only Agent launch results may settle subagents. + // Agent tool_uses project as subagent rows and never enter toolCalls. if ( subagent !== undefined && findToolCall(context, toolResult.tool_use_id) === undefined ) { - // A background Agent launch resolves its tool_use immediately - // with an async-launch ACK while the task keeps running; only - // the eventual task_notification terminalizes the subagent. + // Background Agent launches ACK immediately; only task_notification settles them. if ( !isClaudeToolResultError(toolResult) && (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output)) @@ -8590,6 +8616,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return; } + // Claude Code echoes /compact's stdout ("Not enough messages to compact.") as a + // synthetic assistant message. The compaction item reports that outcome; it is not a + // reply. + if ( + message.type === "assistant" && + "local_command_source" in message && + isCompactionCommandMessage({ role: "user", ...context.input.message }) + ) { + return; + } if (assistantText !== null && assistantText.text.length > 0) { yield* emitAssistantTextArtifacts({ context, @@ -8641,6 +8677,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? null : resultTextFromSdkMessage(message); if ( + // A /compact's result text reports the compaction ("Not enough messages to + // compact."), which its compaction item already carries; it is not a reply. + !( + message.type === "result" && + message.subtype === "success" && + message.local_command === "compact" + ) && context.assistant.emittedNativeItemIds.size === 0 && context.assistant.fallbackText.length === 0 && resultText !== null && @@ -8674,8 +8717,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (message.terminal_reason == null || message.terminal_reason === "api_error" || message.terminal_reason === "blocking_limit"); - // The CLI also labels a 403 authentication_failed. Logging in again - // can't grant a permission, so show the CLI's own text instead. + // The CLI also labels 403 authentication_failed; show its text because login cannot grant access. const permissionDenied = message.subtype === "success" && message.api_error_status === 403; const failureHint = permissionDenied @@ -8684,9 +8726,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (usageLimited ? "Claude usage limit reached. Send the message again once the limit resets." : undefined)); - // A /compact the usage limit stopped still ends in a success - // result. Failing the turn lets the usage-limit guard hold queued - // messages instead of sending them into the same limit (#19327). + // Usage-limited /compact reports success; fail it so queued messages wait for the limit to clear. const limitedCompaction = usageLimited && message.subtype === "success" && message.local_command === "compact" ? context.compactionFailure @@ -8840,8 +8880,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (lifecycle.state === "started") { context.promptStarted = true; } - // Claude finishes a prompt whose uuid its transcript already - // holds without starting it, and no turn will ever answer it. + // Previously recorded prompt uuids complete without starting a turn. if (lifecycle.state === "completed" && !context.promptStarted) { yield* Effect.logWarning("orchestration-v2.claude-prompt-completed-without-turn", { providerTurnId: context.providerTurnId, @@ -8904,9 +8943,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } return; } - // Its output already streamed into this turn, so only its result - // stays out: settling here would end the turn before its prompt - // runs. The prompt's own turn ends it with an echoing result. + // Foreign output already streamed; exclude its result so only the prompt's own echoing result settles the turn. if (isClaudeTaskNotificationOriginResult(message)) { yield* clearWakeReports(liveQuery.nativeThreadId); } @@ -8971,11 +9008,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName !== "ExitPlanMode" && (toolName === "AskUserQuestion" || requiresClaudeApproval(context)) ) { - // The SDK blocks on the answer, and the prompt echo cannot arrive - // until the held turn goes on, so the request is raised now and - // the held output goes with it to the pending prompt turn, as it - // did before this turn was held. ExitPlanMode is answered at once - // below, so its frames stay held. + // The SDK blocks prompt echoes on permission answers; release held output except for ExitPlanMode. yield* releaseHeldRootFrames(context); if (toolName !== "Agent") { yield* ensureToolCallStarted({ @@ -9214,7 +9247,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( question, options: [ { - label: "Compact and continue", + label: CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, description: "Resume with a summary and use fewer tokens.", }, { @@ -9248,7 +9281,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return { behavior: "completed" as const, result: - selection === "Compact and continue" + selection === CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER ? ("compact" as const) : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER ? ("never" as const) @@ -9307,8 +9340,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); - // A continuation prompts nothing: it drains output the live process - // already produced, so it keeps that process whatever its selection. + // Continuations drain existing output without prompting; retain the live process regardless of selection. if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -9365,9 +9397,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return existing; } - // Background agents and shells run inside the CLI process, so a new selection would - // kill them and lose their results. Refuse until they finish or the user presses Stop, - // which closes the process. + // Selection changes kill CLI background work; refuse until it finishes or Stop closes the process. if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -9396,9 +9426,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); - // openedNativeThreads is per session instance and is lost when the - // provider session is idle-released. A prior turn on this native id - // requires resume; sessionId would fail with "already in use". + // Idle release loses openedNativeThreads; persisted turns still require resume to avoid "already in use". const hasPersistedProviderTurn = turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const shouldResume = @@ -9410,6 +9438,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); + const toolSearchOverride = yield* resolveToolSearchOverride(turnInput.runtimePolicy.cwd); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, nativeThreadId, @@ -9443,6 +9472,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( onUserDialog, supportedDialogKinds: ["resume_return"], ...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }), + ...(toolSearchOverride === undefined ? {} : { toolSearchOverride }), }); const querySession = yield* queryRunner .open({ @@ -9462,9 +9492,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : Effect.void, ), ); - // Marked only after a successful open: a failed create must not - // leave the runtime believing the native session exists, or the - // retry would resume a session that was never created. + // Mark only successful opens; retries must not resume sessions that failed to create. yield* Ref.update(openedNativeThreads, (current) => { if (current.has(nativeThreadId)) { return current; @@ -9558,10 +9586,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const startedAt = yield* DateTime.now; const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); const nativeTurnId = `turn:${turnInput.attemptId}`; - // Fresh for every offer: Claude acks a prompt whose uuid its - // transcript already holds without running a turn, and a session - // can outlive this database (or be resumed from a copy of it), so - // a uuid derived from run ids can repeat. + // Use fresh uuids: native sessions can outlive this database, and Claude skips previously recorded prompts. const promptUuid = isClaudeProviderContinuationTurn(turnInput) ? null : yield* crypto.randomUUIDv4().pipe( @@ -9725,8 +9750,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* querySession.query.offer(userMessage); return; } - // Transcript reads suspend replay. Hold live SDK frames until the whole - // buffer has been applied so starts, completions and the root result stay ordered. + // Transcript reads suspend replay; hold SDK frames until the buffer is applied to preserve order. yield* sdkMessageLock.withPermits(1)( Effect.gen(function* () { if ( @@ -9750,12 +9774,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* finalizeActiveTurn({ context, status: "completed", completedAt }); return; } - // Replay any result message last: a result finalizes the turn, and - // replaying it before the rest would drop them back into the wake - // buffer and request another continuation. When Claude dequeues - // several notifications at once it can end an empty turn and open - // the next within a millisecond; that next turn is still running - // and owns this run, so the empty turn's result must not settle it. + // Replay results last to avoid rebuffering later frames. An empty turn's result must not settle a newer running turn. const lastResultIndex = drained.findLastIndex((entry) => entry.type === "result"); const tailResult = drained[lastResultIndex]; const emptyTurnBeforeNextTurn = @@ -9953,10 +9972,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); - // A steer is sent with priority "now", which cancels the root turn's - // running tool calls. A subagent the root launched in the foreground is - // one of them, so it moves to the background first and keeps running; - // its result arrives as a task notification like any background agent. + // Move subagents to the background before priority "now" steers cancel foreground tools. const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, deadline: number, @@ -10131,10 +10147,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); yield* Effect.addFinalizer(() => closeSession()); - // A CLI stopped before it saved the first prompt can leave a transcript - // with only metadata lines. Claude can neither resume that id nor - // create it again, so resumeThread fails and the caller binds a fresh - // native session with a context handoff. + // Metadata-only transcripts cannot be resumed or recreated; fail resumeThread to trigger a fresh session and handoff. const hasEmptyClaudeTranscript = Effect.fn("ClaudeAdapterV2.hasEmptyClaudeTranscript")( function* (nativeThreadId: string) { const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; @@ -10144,8 +10157,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : path.join(yield* HostProcess.HomeDirectory, ".claude"), "projects", ); - // Empty only when every copy of the id is empty; any message means - // the CLI may still resume it. + // Any message in any transcript copy means Claude may still resume it. let found = false; for (const project of yield* fileSystem.readDirectory(projectsDir)) { const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`); @@ -10254,7 +10266,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId !== null && // A live process holds the conversation even before saving it. (yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId && - // An unreadable transcript leaves the resume to the CLI, as before. + // An unreadable transcript leaves the resume to the CLI. (yield* hasEmptyClaudeTranscript(nativeThreadId).pipe( Effect.orElseSucceed(() => false), )) @@ -10321,6 +10333,40 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); }), + awaitBackgroundTasks: Effect.fn("ClaudeAdapterV2.awaitBackgroundTasks")( + function* ({ providerThread, taskIds, awaited }) { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId == null) return { matched: [] }; + const roster = rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ); + const matched = [...new Set(taskIds)].filter((taskId) => roster.has(taskId)); + if (matched.length === 0) return { matched }; + if (awaited) { + yield* addTaskIdsToNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + matched, + ); + } else { + for (const taskId of matched) { + yield* clearTaskIdFromNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ); + } + } + const active = yield* Ref.get(activeTurn); + yield* emitRosterUpdate( + nativeThreadId, + active?.nativeThreadId === nativeThreadId ? active : null, + ); + return { matched }; + }, + (effect) => sdkMessageLock.withPermits(1)(effect), + ), interruptTurn, stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { const existing = yield* Ref.get(queryContext); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 9a4a7ba3cc7a..2ba8f78c451b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -136,9 +136,7 @@ const openFakeCli = (cli: string) => }); describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI process", () => { - // The CLI stops the MCP servers it started, but not their children: a - // plugin's `bun server.ts` outlives its `bun run` wrapper (#3089). The child - // holds a FIFO open, so its death reads as end-of-file here, with no polling. + // The leftover child holds a FIFO open; its death produces EOF without polling. it.effect("stops what the CLI left running when the session closes", () => Effect.gen(function* () { const cli = fakeClaudeCli( @@ -187,7 +185,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro ); // The CLI keeps running its turn after its pipes close, so a server that - // crashed would leave it working next to the session it resumes (#23370). + // crashed would leave it working next to the session it resumes. it.effect("stops the CLI when the server that started it goes away", () => Effect.gen(function* () { const cli = fakeClaudeCli(['dir=$(dirname "$0")', 'sleep 600 > "$dir/alive"'].join("\n")); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 2a06d25c5030..87992bab8916 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -12,7 +12,6 @@ import { tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; const SESSION_ID = "0c91bb28-9e89-4889-bd22-3de9880233d3"; const TASK_ID = "bk6xreeye"; -// Claude Code's layout: ///tasks/.output. const makeTaskFile = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -21,8 +20,7 @@ const makeTaskFile = Effect.gen(function* () { return { root, tasks, file: path.join(tasks, `${TASK_ID}.output`) }; }); -// Live clock and a real file: this proves the tail follows the writer, which -// TestClock cannot interleave with filesystem IO. +// TestClock cannot interleave with real filesystem I/O; use the live clock. describe("tailClaudeTaskOutput", () => { it.live("follows a file that appears after the task starts, across split characters", () => Effect.scoped( @@ -39,7 +37,7 @@ describe("tailClaudeTaskOutput", () => { }).pipe(Effect.forkScoped); yield* fileSystem.makeDirectory(tasks, { recursive: true }); - // "é" is two bytes; the first write ends between them. + // Split the two-byte é across writes. const encoded = new TextEncoder().encode("café 1\n"); yield* fileSystem.writeFile(file, encoded.subarray(0, 4)); assert.equal(yield* Queue.take(chunks), "caf"); @@ -97,7 +95,6 @@ describe("tailClaudeTaskOutput", () => { ), }).pipe(Effect.forkScoped); yield* Deferred.await(first); - // Written right before the command exits, then the result arrives. yield* fileSystem.writeFileString(file, "one\ntwo\n"); yield* Deferred.succeed(stop, undefined); yield* Fiber.join(fiber); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index fbe759e973a4..6ab23fddbe16 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts @@ -4,22 +4,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -/** - * Live output for a foreground Claude Bash call. - * - * The Agent SDK sends no stdout until the tool result. Claude Code does write - * each running command's combined output to - * `/claude-///tasks/.output`, naming - * the task in its `task_started` frame (`task_type: "local_bash"`). This path is - * Claude Code's private layout, not SDK API, so every step degrades to "no live - * output": the row still gets its full output from the tool result. - */ +/** The SDK withholds stdout until tool results; tail task_started files for live output. + * The private Claude layout can change; failures omit live output and retain the final result. */ -/** How often the file is checked while its command runs. */ const CLAUDE_TASK_OUTPUT_POLL_MS = 250; -/** Give up looking for a file Claude Code never wrote (a newer layout, or Windows). */ +/** Bound discovery when Claude never writes the file. */ const MAX_LOCATE_ATTEMPTS = 40; -/** Bytes read per poll. A faster writer skips ahead: only the tail is shown anyway. */ +/** Skip ahead of fast writers; only the tail is displayed. */ const MAX_READ_BYTES = 256 * 1024; /** Claude Code's per-user temp root, or none where it has no uid (Windows). */ @@ -56,12 +47,7 @@ const locate = Effect.fn("ClaudeTaskOutputTail.locate")(function* (input: { return Option.none(); }); -/** - * Reads appended output until `stop` completes, then reads once more so the - * last bytes written before the command exited still reach live viewers. The - * caller completes `stop` when the tool result arrives or the turn ends, and - * awaits the returned effect. - */ +/** Complete stop on tool result or turn end, then await the effect; it performs a final read before returning. */ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(function* (input: { readonly root: string; readonly sessionId: string; @@ -70,7 +56,6 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct readonly onChunk: (chunk: string) => Effect.Effect; }) { const fileSystem = yield* FileSystem.FileSystem; - // True once stopped: the next read is the last. const pause = Effect.raceFirst( Effect.sleep(CLAUDE_TASK_OUTPUT_POLL_MS).pipe(Effect.as(false)), Deferred.await(input.stop).pipe(Effect.as(true)), @@ -82,8 +67,6 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct if (attempt + 1 >= MAX_LOCATE_ATTEMPTS || (yield* pause)) return; } const filePath = file.value; - // `stream: true` keeps a multi-byte character split across reads intact; - // invalid bytes become U+FFFD. let decoder = new TextDecoder("utf-8", { fatal: false }); let offset = 0; let last = yield* Deferred.isDone(input.stop); @@ -105,7 +88,7 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct if (Option.isSome(bytes)) { // Skipped output must not splice two unrelated lines together. const skipped = start > offset; - // Bytes held from before the gap belong to a character we never finished reading. + // Reset partial UTF-8 bytes when skipping output. if (skipped) decoder = new TextDecoder("utf-8", { fatal: false }); offset = start + bytes.value.byteLength; const text = (skipped ? "\n" : "") + decoder.decode(bytes.value, { stream: true }); @@ -113,7 +96,6 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct } } if (last) { - // A command that ended mid-character still shows a replacement character. const rest = decoder.decode(); if (rest.length > 0) yield* input.onChunk(rest); return; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index e925c537f5c7..a40f6c07947a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -737,8 +737,7 @@ describe("CodexAdapterV2 session initialize", () => { Effect.gen(function* () { const initializeAwaitingResponse = yield* Deferred.make(); const releaseInitialize = yield* Deferred.make(); - // The transcript allows exactly one handshake: a second `initialize` - // frame fails the replay, as Codex rejects it with "Already initialized". + // Codex rejects a second initialize; the replay permits one handshake. const session = yield* openReplaySession( makeCodexReplayTranscript({ scenario: "concurrent-initialize", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 73d1e1f64987..0b9d8de53331 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -180,8 +180,7 @@ function codexFileChangeApprovalPrompt(input: { return input.grantRoot?.trim() || undefined; } -// Reasoning effort changes future generation, not the existing context's tokenizer -// or model window. All other option changes remain untrusted until new telemetry. +// Reasoning-effort changes preserve the tokenizer and context window; other changes need fresh telemetry. export function canReuseCodexContextUsage(previous: ModelSelection, next: ModelSelection): boolean { return modelSelectionsEqual( { @@ -208,8 +207,7 @@ export function codexProviderTurnTokenUsage( } const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({}); const CODEX_ASSISTANT_DELTA_FLUSH_INTERVAL_MS = 50; -// Each plan flush stores the whole plan three times (node, plan, turn item), so -// flush at the cadence clients already see for assistant text. +// Plan flushes store three copies; use the client's assistant-text cadence. const CODEX_PLAN_DELTA_FLUSH_INTERVAL_MS = 400; const CodexBackgroundTerminalTerminateResponse = Schema.Struct({ terminated: Schema.Boolean, @@ -486,8 +484,7 @@ function codexMcpToolOutput( : { error: item.error.message, result: resultOutput }; } -// Reading an app's resource is one MCP round trip; a server that hangs must not -// hold the item (and the run's ingestion) open. +// A hung MCP resource read must not hold run ingestion open. const MCP_APP_CAPTURE_TIMEOUT = "20 seconds"; function codexMcpAppResourceUri(item: CodexDynamicToolItem): string | undefined { @@ -763,9 +760,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.hasT3Mcp !== true ? undefined : buildCodexDeveloperInstructions(input.runtimePolicy.interactionMode); - // An app's context is text an MCP server wrote, so it goes in as untrusted - // context: Codex renders it as quoted user-side input, never as developer - // instructions. + // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ entry.key, @@ -807,11 +802,9 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ...(additionalContext ? { additionalContext } : {}), cwd: input.runtimePolicy.cwd, model: input.modelSelection.model, - // Model catalogues can default summaries to "none". Request them on every - // turn, including resumed threads, for T3's reasoning timeline. + // Catalogues can default summaries to "none"; request them explicitly on every turn. summary: "detailed", - // Always explicit: omitting this on resume leaves Codex's previous - // reviewer sticky after switching away from Auto mode. + // Omitting the reviewer on resume preserves the previous Auto reviewer. approvalsReviewer: runtimeModeDefaults.approvalsReviewer, ...(approvalPolicy === undefined ? {} : { approvalPolicy }), ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), @@ -895,8 +888,7 @@ function providerThreadFromCodexThread(input: { }; } -// Counts toward native rollback. `/goal` control turns never reach Codex and -// carry no native ref, so they must not consume a native turn on revert. +// /goal control turns never reach Codex and must not count toward native rollback. const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.nativeTurnRef !== null && (turn.status === "completed" || @@ -1004,11 +996,7 @@ const resolveCodexForkRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveForkR }, ); -/** - * `lastTurnId` is inclusive on the Codex side, so a fork requested at the - * selected turn omits every later turn atomically. That matters for paginated - * threads. - */ +/** Codex lastTurnId is inclusive, atomically omitting later turns even in paginated threads. */ export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBoundary")(function* ( input: ProviderAdapter.ProviderAdapterV2ForkThreadInput, ) { @@ -1029,10 +1017,7 @@ export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBou return { lastTurnId: nativeTurnId, rollbackTurnCount: 0 }; }); -/** - * The generated `thread/read` response schema does not surface `historyMode`, - * so the probe goes through the raw request channel with a permissive decode. - */ +/** Generated thread/read schemas omit historyMode; probe through the raw request channel. */ const CodexThreadHistoryMetadata = Schema.Struct({ thread: Schema.Struct({ historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])), @@ -1344,18 +1329,13 @@ export class CodexAppServerClientFactory extends Context.Service< CodexAppServerClientFactoryShape >()("t3/orchestration-v2/Adapters/CodexAdapterV2/CodexAppServerClientFactory") {} -/** - * Codex waits this long for MCP servers before the first model request and - * leaves out any server still starting (default 1 s, too short for cold - * `npx`/`uvx` servers). - */ +/** MCP startup grace in milliseconds; the 1 s default excludes cold npx/uvx servers from the first model request. */ export const CODEX_THREAD_CONFIG = { "tools.update_plan.enabled": true, mcp_optional_startup_grace_ms: 10_000, } as const; -// T3 wait tools can block for up to an hour. Keep Codex's MCP timeout above -// that limit so the server's own wait timeout ends a long call. +// T3 waits can block for an hour; keep Codex's MCP timeout above that. const CODEX_T3_MCP_TOOL_TIMEOUT_SEC = 65 * 60; function codexThreadRuntimeParams(input: { @@ -1722,10 +1702,7 @@ export interface CodexAdapterV2Options { readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly serverConfig: ServerConfig["Service"]; - /** - * Optional: adapters that omit it keep projection-only handling for late item - * completions. - */ + /** Omitting this leaves late completions projection-only. */ readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -1813,8 +1790,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ); const initialized = yield* Ref.make(false); - // Threads share this app-server, and Codex rejects a second - // `initialize`. + // Codex rejects a second initialize on the shared app-server. const initializePermit = yield* Semaphore.make(1); const ensureInitialized = initializePermit.withPermit( Effect.gen(function* () { @@ -1830,8 +1806,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }) .pipe( Effect.catchTags({ - // A caller interrupted after its `initialize` reached Codex - // leaves the app-server initialized but the flag unset. + // Interruption can leave Codex initialized before the local flag is set. CodexAppServerRequestError: (error) => error.code === -32600 && error.errorMessage === "Already initialized" ? Effect.void @@ -1900,19 +1875,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const pendingRuntimeRequests = yield* Ref.make( new Map(), ); - /** - * Codex's own acceptForSession only skips the exact command or patch it - * answered, so every new command would ask again. - */ + /** Codex acceptForSession covers only the exact command or patch, not future commands. */ const sessionApprovals = new Map< string, { readonly runtimeMode: RuntimeMode; readonly kinds: Set } >(); - /** - * The allowed kinds for an approval request's turn. Read it when the - * request arrives and add to that same set when the user answers, so a - * late answer from a turn whose mode has since ended is dropped. - */ + /** Bind answers to the request's approval set so mode changes invalidate late answers. */ const endSessionApprovalsOnModeChange = (context: ActiveCodexTurnContext) => { const nativeThreadId = context.providerThread.nativeThreadRef?.nativeId; if ( @@ -1935,11 +1903,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( sessionApprovals.set(nativeThreadId, { runtimeMode, kinds }); return kinds; }; - /** - * Turn contexts retained past turn/completed while background command - * items started in that turn are still running, so late item events - * keep projecting instead of being dropped. - */ + /** Retain settled contexts while background commands run so late item events still project. */ const settledTurns = yield* Ref.make(new Map()); const runningCommandItemsByTurn = yield* Ref.make( new Map>(), @@ -1949,22 +1913,19 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); const interruptingNativeTurns = yield* Ref.make(new Set()); const terminalizedNonCompletedNativeTurns = yield* Ref.make(new Set()); - // Keep the run event stream open until descendant provider state is - // terminal, otherwise the root terminal can strand child projections. + // Keep the stream open until descendants settle to avoid stranding child projections. const deferredRootTerminals = yield* Ref.make(new Map()); const offeredContinuationItemsByTurn = yield* Ref.make(new Map>()); const finalAnswerItemIdsByTurn = yield* Ref.make(new Map>()); const completedFinalAnswerTextsByTurn = yield* Ref.make(new Map>()); - // Native completion and the interrupt timeout share one finalization - // path. Serialize the race so only one can publish terminal events. + // Serialize native completion and interrupt timeout so only one publishes terminal events. const turnTerminalizationPermit = yield* Semaphore.make(1); const goalsByNativeThread = new Map(); const rootProviderThreads = new Map(); const goalHolds = new Map(); const goalRuns = new Map>(); const goalActivations = new Map(); - // Stops in progress by native thread. Until a Stop resolves its target, - // a held run must not settle as completed. + // Held runs must not complete while Stop resolves its target. const goalStops = new Map(); const latestGoalTurnId = (providerTurnId: ProviderTurnId) => goalRuns.get(providerTurnId)?.at(-1)?.providerTurnId ?? providerTurnId; @@ -1995,8 +1956,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* emitGoalUpdate(nativeThreadId); }); - // Call only for new model-output activity. A local item/completed can - // arrive while the upstream response stream is still retrying. + // Only new model output proves recovery; local item/completed can arrive during upstream retries. const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* ( context: ActiveCodexTurnContext, updatedAt: DateTime.Utc, @@ -2361,9 +2321,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.catchTags({ CodexAppServerProcessExitedError: () => Effect.succeed({ terminated: true }), CodexAppServerInputStreamEndedError: () => Effect.succeed({ terminated: true }), - // The thread is unloaded, as Codex does a minute after a - // settle or archive unsubscribes it. Unloading kills the - // thread's terminals, so nothing is left to stop. + // Unloading kills terminals, so "thread not found" means nothing remains to stop. CodexAppServerRequestError: (error) => error.code === -32600 && error.errorMessage.startsWith("thread not found:") ? Effect.succeed({ terminated: true }) @@ -2509,8 +2467,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const now = yield* DateTime.now; const prior = input.subagent.task; const settled = !isOrchestrationV2WorkActive(prior.status); - // Snapshots can arrive after completion. Only an actual new turn - // may reopen the reusable child identity. + // Late snapshots must not reopen completed children; only new turns may. if (settled && isOrchestrationV2WorkActive(input.status) && !input.reopen) return; const status = settled && !input.reopen ? prior.status : input.status; const completedAt = isOrchestrationV2WorkActive(status) @@ -3326,7 +3283,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return { node, message, turnItem }; }); - // Reuse the text coalescer so token bursts do not create one database write per token. + // Coalesce token bursts to avoid a database write per token. const reasoningParts = new Map< string, { turnId: string; nativeItemId: string; stream: "summary" | "content"; index: number } @@ -3391,7 +3348,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (context === undefined || payload.delta.length === 0) return; yield* completeProviderRetry(context, yield* DateTime.now); const itemId = reasoningPartKey(payload.turnId, payload.itemId, stream, index); - // Reserve the position before the delayed flush, ahead of later tool items. + // Reserve positions before delayed flushes so later tools stay after them. yield* resolveItemOrdinal(context, itemId); yield* reasoningDeltas.append({ turnId: payload.turnId, itemId, delta: payload.delta }); }); @@ -3822,10 +3779,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( item: Extract, resourceUri: string, ) => - // Only reading and storing the document can be cancelled; a write - // cut short removes its file. Once a document is stored, the capture - // either lands it on the item or, when its turn already settled the - // item, removes it, so no stored app goes unreferenced. + // Only reads and storage are cancellable; remove partial writes. Once stored, attach the app or delete it if the item settled. Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const reference = @@ -3851,10 +3805,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ), Effect.provideService(FileSystem.FileSystem, fileSystem), - // Every failure and defect becomes a plain tool row, so - // a crashed read never holds the turn open; only - // interruption, the turn cancelling the capture, ends - // the fiber here. + // Convert failures and defects to plain tool rows; only interruption ends the capture fiber. Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => @@ -3868,8 +3819,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) : undefined; const artifacts = yield* buildDynamicToolArtifacts(context, item); - // Cleared before the final emit: ingestion re-checks pending work - // after each event, and must see this capture done by then. + // Clear before emitting; ingestion rechecks pending work after each event. if (!(yield* takePendingMcpAppCapture(nativeTurnId, item.id))) { if (reference !== undefined) { const filePath = resolveAttachmentPathById({ @@ -4321,8 +4271,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("item/commandExecution/outputDelta", (payload) => Effect.gen(function* () { - // A delta always follows its item/started, so the turn is already - // known; background commands keep printing after it settles. + // Background commands emit deltas after settlement; item/started already registered their turn. const context = (yield* Ref.get(activeTurns)).get(payload.turnId) ?? (yield* Ref.get(settledTurns)).get(payload.turnId); @@ -4528,9 +4477,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* Deferred.succeed(goalHold.next, next); return; } - // A goal turn with no run to own it (Codex continued after the run - // settled, or raced a Stop): stop it and pause the goal rather than - // work out of sight. + // Pause orphan goal turns so they cannot work without an owning run. if ( rootProviderThreads.has(payload.threadId) && (goalsByNativeThread.get(payload.threadId)?.status === "active" || @@ -4881,8 +4828,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("item/completed", (payload) => Effect.gen(function* () { if (payload.item.type === "contextCompaction") - // The notification callback runs on the input reader. Let it read - // the injection response while the session-scoped request waits. + // Fork requests off the notification reader so it can read their responses. yield* restoreAdditionalContext(payload.threadId).pipe( Effect.forkIn(scope, { startImmediately: true }), ); @@ -5802,10 +5748,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const hasActiveDescendants = Array.from((yield* Ref.get(activeTurns)).values()).some( (candidate) => isDescendantCodexTurn(candidate, input.context), ); - // Native subagents run on their own threads, so a failed root leaves - // them working. Holding the root until they finish would keep the - // run open with no turn to steer. Stop interrupts the whole lineage, - // so an interrupted root still waits for its descendants to settle. + // Failed roots leave native children working and must settle without them. + // Interrupted roots wait for descendants because Stop interrupts the whole lineage. if (event.status === "failed" && hasActiveDescendants) { yield* emitRootTerminal(input.context, { ...event, childTurnsContinue: true }); return; @@ -5842,10 +5786,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); - /** - * Call with the terminalization permit, after removing the hold from - * `goalHolds`. - */ + /** Hold the terminalization permit and remove the goalHolds entry before calling. */ const settleGoalHold = Effect.fnUntraced(function* ( hold: CodexGoalHold, status: "completed" | "interrupted", @@ -6065,11 +6006,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( status: input.status, completedAt: input.completedAt, }); - // Codex does not wake a parent whose turn already completed, so - // the child's result would sit unread until the next message. - // Only direct children of a root turn wake it: a grandchild - // reports to its own parent, and a root turn already running on - // the thread receives the result natively. + // Codex does not wake a completed parent turn, leaving child results unread. + // Only direct children wake an idle root turn; grandchildren report to their + // own parent, and running root turns receive results natively. const parent = input.context.subagent.parentContext; if ( continuationRequests !== undefined && @@ -6144,9 +6083,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( updated.set(input.nativeTurnId, input.context); return updated; }); - // Background work that finished between the check above and - // the turn being recorded as settled saw no settled turn to - // release, so it is re-checked once recorded. + // Recheck after recording settlement; background work may have finished before a settled turn existed to release. yield* releaseSettledTurnIfIdle(input.nativeTurnId); } yield* Ref.update(activeTurns, (current) => { @@ -6323,12 +6260,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( : undefined; }; - /** - * Codex can end or continue the turn while the pause is in flight, so - * the target resolves after it: a held run settles here and leaves only - * its retained work to stop, and a continued run moves Stop to its - * newest turn. - */ + /** Resolve Stop after pausing; Codex can settle or continue to a newer turn while pause is in flight. */ const resolveGoalStopTarget = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput, ) { @@ -6499,7 +6431,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } if (command.type === "set") { if (current !== null) yield* client.request("thread/goal/clear", { threadId }); - // Paused until our turn runs, so Codex does not start one first. + // Keep paused until our turn starts so Codex cannot start one first. const { goal } = yield* client.request("thread/goal/set", { threadId, objective: command.objective, @@ -6507,8 +6439,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); goalsByNativeThread.set(threadId, providerGoalFromCodex(goal)); } - // Until the goal is active, a fast first turn still holds the run, - // and Stop cancels the activation. + // Hold fast first turns until activation; Stop cancels activation. const activation = { stopped: false }; goalActivations.set(threadId, activation); yield* Effect.gen(function* () { @@ -6637,10 +6568,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( providerSession: session, events: Stream.fromEffectRepeat(Queue.take(events)), canReuseContextUsage: canReuseCodexContextUsage, - // Known gap: a subagent that Codex resumes later reads as completed - // (not pending) between turns, so idle release can win the race - // against a long-delayed resume. Codex emits no resume-expected - // signal to pin on. + // Idle release can beat delayed subagent resumes: Codex reports them completed between turns with no resume-expected signal. hasPendingBackgroundWork: Effect.gen(function* () { for (const items of (yield* Ref.get(runningCommandItemsByTurn)).values()) { if (items.size > 0) { @@ -6789,8 +6717,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* Ref.update(pendingRootTurns, (current) => new Map(current).set(threadId, turnInput), ); - // Cleared on interrupt too, as startTurn does: an interrupted start - // must not adopt the native turn that a late turn/started reports. + // Clear on interruption; a late turn/started must not be adopted after an interrupted start. yield* client.request("thread/compact/start", { threadId }).pipe( Effect.onError(() => Ref.update(pendingRootTurns, (current) => { @@ -6825,9 +6752,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }) .pipe( Effect.as(true), - // Older app servers reject unknown methods before mutating history. - // Transport errors and invalid payloads are ambiguous and must not - // fall through to a second delivery in the current user message. + // Retry only unknown-method errors, which precede history mutation. Transport or payload errors leave delivery ambiguous. Effect.catchTags({ CodexAppServerRequestError: (error) => error.code === -32601 ? Effect.succeed(false) : Effect.fail(error), @@ -7162,8 +7087,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* Effect.gen(function* () { for (const target of interruptTargets) { const context = target.context; - // A null start timestamp acknowledges a queued turn; Codex cannot interrupt it - // until turn/started confirms that the native task exists. + // Null start timestamps ACK queued turns; interruption must wait for turn/started. if (context.nativeStartReady !== undefined) { const ready = yield* Deferred.await(context.nativeStartReady).pipe( Effect.timeoutOption("10 seconds"), @@ -7456,9 +7380,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( cause: `Cannot roll back Codex thread ${threadId}: the thread uses legacy history, which Codex 0.156 cannot revert.`, }); } - // `thread/revert` only acts on a thread loaded in this app-server - // process. After a restart or idle release, load it the same way - // the next turn would before reverting. + // thread/revert requires the thread loaded in this app-server; resume after restart or idle release. if (!loaded) { yield* client.raw.request("thread/resume", { threadId, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index e9c256624c4c..f324de430b71 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -516,8 +516,7 @@ export function layer( readonly assertCompleteOnFinalize?: boolean; }, ) { - // Skill discovery also scans user roots under HOME; an empty HOME keeps - // replays from picking up the host's own skills. + // Empty HOME prevents skill discovery from using the host's skills. const layerHostEnvironment = Layer.effect( HostProcess.Environment, Effect.gen(function* () { @@ -717,8 +716,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( model: cursorSdkModelSelection(input.modelSelection), mode: interactionMode === "plan" ? "plan" : "agent", } as const; - // Agents sometimes search the workspace's parent too; map it to /tmp so the - // recording host's temp layout stays out of the fixture. + // Agents search parent directories too; normalize the recording host's temp layout. const replacements: ReadonlyArray = [ [input.cwd, input.transcriptCwd ?? `/tmp/cursor-replay-${input.scenario}`], [input.cwd.slice(0, input.cwd.lastIndexOf("/")), "/tmp"], @@ -760,10 +758,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( } }; - // In a mid-tool interrupt recording, frames after the first tool-call-started - // are held until run.cancel is recorded, so the cancel directly follows its - // trigger. Holding appends rather than waits: updates can arrive while send - // is still flushing them, and blocking there would never return. + // Hold frames after tool-call-started until run.cancel. Append without waiting because updates can arrive during send. let awaitingToolStart = input.interruptAfterToolStart === true; let heldUntilCancel: Array | undefined; @@ -842,10 +837,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( } if (input.interruptAfterToolStart === true) { yield* awaitSignal(toolStarted, "Cursor SDK tool-call-started before interrupt"); - // Cancelling synchronously from the SDK's tool-call-started callback - // leaves an unhandled AbortError inside @cursor/sdk that kills the - // process (reproduced on 1.0.22, 1.0.31, and 1.0.32). Cancelling from a - // later timer was clean in the same probes; 10 ms is that deferral. + // Synchronous tool-call-started cancellation crashes @cursor/sdk with AbortError (1.0.22, 1.0.31, 1.0.32); defer 10 ms. yield* Effect.sleep("10 millis"); const cancelling = yield* run.cancel.pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.succeed(cancelSent, undefined); diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index 1f39d3a40229..aeedce64deb4 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -200,12 +200,7 @@ interface MuseReplayHost { open: boolean; } -/** - * A write that matches a later outbound record of the same turn is held until - * the transcript reaches it, because independent adapter fibers can reach stdin - * in a different order than recorded. A write that matches nothing before the - * next turn fails at once. - */ +/** Adapter fibers can reorder stdin writes; hold matches within the turn until reached, and fail unmatched writes. */ class MuseReplayController { private cursor = 0; private failure: unknown = null; @@ -426,7 +421,6 @@ export function layer(input: { settings: DEFAULT_MUSE_SETTINGS, environment: input.environment ?? {}, createHost: input.createHost, - // Same queue the continuation worker drains when the fixture runs it. continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, }), ]; @@ -442,11 +436,7 @@ export function layer(input: { ); } -/** - * Fills a recording's `` with what the adapter sends on this host: - * the canonical path (macOS /var -> /private/var), which `turn/start` names in - * museWorkspaceRoot's form (verbatim on Windows). - */ +/** Uses canonical workspace paths, with turn/start in museWorkspaceRoot form (verbatim on Windows). */ export const materializeMuseReplayWorkspace = Effect.fn("materializeMuseReplayWorkspace")( function* (transcript: ProviderReplayTranscript, workspace: string) { const canonical = yield* FileSystem.FileSystem.pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 15ca24556ad6..ca0288ab2c5e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1946,8 +1946,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("reads the agents again while a fresh server still lists none", () => Effect.gen(function* () { - // 2.0.18 answers `/api/agent` with `[]` for a moment after it starts; - // rules written from that would drop the agents' own paths. + // OpenCode 2.0.18 briefly returns no agents on startup; using that response drops agent paths. const runtime = yield* openCode2ReplayRuntime([ ...opening, out("agent.list", ""), @@ -1984,7 +1983,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }), reply("message.list", { data: [ - // OpenCode appends an `idle` item after each execution it ends. ...(ended === undefined ? [] : [{ id: "msg_idle_gap", time: { created: 3 }, type: "idle", outcome: ended }]), @@ -2108,8 +2106,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("finishes a workspace command whose stream dropped before its inbox event", () => Effect.gen(function* () { - // `session.command` answers 204 without the inbox item's id, and the - // stream drops before `session.inbox.enqueued` would have named it. + // session.command returns 204 without an inbox id; the stream drops before session.inbox.enqueued provides it. const { runtime, thread } = yield* resumed([ out("command.list", ""), reply("command.list", { location: { directory: WORK }, data: [{ name: "hello" }] }), @@ -3183,9 +3180,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { status: 500, body: { _tag: "UnknownError", message: "disk I/O error" }, }), - // Uncleared, OpenCode would commit the stage on the next prompt. The - // clear wakes the session into an empty execution of its own, with no - // turn of T3's running: it is no subagent's follow-up. + // Uncleared stages commit on the next prompt; clearing triggers an empty execution without a T3 turn. out("session.revert.clear", { sessionID: SESSION }), reply("session.revert.clear", null), event("session.revert.cleared", { sessionID: SESSION }), @@ -3233,11 +3228,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("prompts under ids no other session on the server can hold", () => Effect.gen(function* () { - // Two T3 databases on one external server repeat thread ids and run - // ordinals, so their turns can share an attempt id and their steers a - // message id. OpenCode refuses a prompt id another session already - // holds with 409 ConflictError; the replay refuses a client id it - // already gave another recorded message. + // Separate databases can repeat prompt ids on one server; OpenCode rejects cross-session duplicates with 409. const OTHER = "ses_f148ca2deffeOtherSession000"; const steerInto = (session: string, recorded: string): ReadonlyArray => [ out("session.prompt", { @@ -3383,7 +3374,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ); yield* runtime.startTurn(turnInput(thread)); yield* runtime.startTurn(turnInput(other)); - // The second turn starts and the first ends while the session stays running. assert.deepEqual( [...(yield* Fiber.join(collected))].flatMap((event) => event.type === "provider_session.updated" ? [event.providerSession.status] : [], diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 0b29a15d9e9b..fd868921f744 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -149,8 +149,7 @@ const replayHttpClient = ( query, raw === undefined ? undefined : decodeJson(raw), ); - // The event stream and requests are separate connections: a request - // is matched only once what was recorded before it was delivered. + // HTTP streams and requests use separate connections; deliver prior recorded inbound data before matching requests. if (operation.type !== "event.subscribe") await controller.untilInboundDelivered(); if (operation.type === "session.prompt") onPrompt?.(operation.input); await controller.expectOutbound(operation); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 08e4fc9066ab..13ee0a8a9774 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -232,11 +232,7 @@ export class OpenCodeReplayController { } } - /** - * Like {@link untilEventsDelivered}, but also waits for recorded responses to - * be taken: over HTTP a request can start while an earlier one's answer is - * still in flight, and must not be matched against that answer. - */ + /** Waits for events and responses to be consumed so concurrent HTTP requests cannot match earlier answers. */ async untilInboundDelivered(): Promise { while (true) { this.throwFailure(); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index 0a1ebab3c5a3..0ca1005f1049 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -119,11 +119,7 @@ function adapterRequestId(frame: unknown): string | undefined { return typeof id === "string" && ADAPTER_REQUEST_ID.test(id) ? id : undefined; } -/** - * Compares a recorded request against the adapter's own request id, so the - * correlation Pi recorded holds without pinning counter values. Any other id, - * such as the Pi request id an `extension_ui_response` must echo, stays exact. - */ +/** Rebind adapter request ids without pinning counters; provider ids that responses must echo stay exact. */ function withRequestId(expected: unknown, actual: unknown): unknown { const actualId = adapterRequestId(actual); return isRecord(expected) && adapterRequestId(expected) !== undefined && actualId !== undefined @@ -181,13 +177,7 @@ interface PiReplayProcess { open: boolean; } -/** - * Independent adapter fibers (skill discovery beside thread setup, a settle - * probe beside a steer) can reach stdin in a different order than recorded, so - * a write that matches a later outbound record of the same turn is held until - * the transcript reaches it. A write that matches nothing before the next - * turn-starting record fails immediately. - */ +/** Adapter fibers can reorder stdin writes; hold matches within the turn until reached, and fail unmatched writes. */ class PiReplayController { private cursor = 0; private failure: unknown = null; diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts index 2bb43e53ca6b..1653e5b42f1d 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts @@ -82,11 +82,7 @@ function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { }; } -/** - * A background launch answers the model with an acknowledgement rather than the run's output, and - * that acknowledgement is the only place the run's filesystem handles appear — nothing later on - * the event stream repeats them. - */ +/** The background-launch ACK is the only frame carrying the run's filesystem handles. */ export function parseClaudeWorkflowRunHandles( toolUseResult: unknown, ): OrchestrationV2WorkflowRunHandles | undefined { @@ -119,12 +115,7 @@ function parseClaudeWorkflowProgress(message: unknown): { return phases.length === 0 && agents.length === 0 ? null : { phases, agents }; } -/** - * Returns undefined when nothing identifies the task as a workflow. Progress frames do not repeat - * `task_type`, so this is the single place that decides whether a task is a coordinator; without - * it every ordinary subagent's usage frame would mint an empty workflow and render as a - * coordinator. - */ +/** Returns undefined without workflow evidence; progress frames omit task_type and ordinary subagents also emit usage. */ export function mergeClaudeWorkflowProgress(input: { readonly previous: OrchestrationV2SubagentWorkflow | undefined; readonly name?: string | undefined; diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts index 324ff6b1fb8d..de128992d743 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts @@ -177,9 +177,7 @@ describe("AttachmentClaims", () => { const copyArgs = yield* Deferred.make(); const copyDone = yield* Deferred.make(); - // fs.copyFile cannot be cancelled: the write runs on a separate fiber - // while the claim's copyFile only observes its completion, so the file - // still lands after the awaiting effect is interrupted. + // fs.copyFile cannot be cancelled; its separate writer must finish after the awaiting effect is interrupted. const gatedFileSystem = Object.create(fileSystem, { copyFile: { value: (from: string, to: string) => diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.ts b/apps/server/src/orchestration-v2/AttachmentClaims.ts index 40955e74a12f..e20d18014bf5 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.ts @@ -51,10 +51,7 @@ export const releaseClaimedAttachments = Effect.fn("AttachmentClaims.releaseClai }, ); -/** - * A copy, not a move — the pending file stays behind as the retry source for a - * failed bootstrap, and the periodic pending sweep reclaims it later. - */ +/** Copies preserve the pending retry source after failed bootstrap; the pending sweep reclaims it. */ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingAttachments")( function* (input: { readonly threadId: string; @@ -120,11 +117,8 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`, }); } - // A copy, not a hard link: an agent editing the delivered file in - // place must not mutate the retry source. fs.copyFile cannot be - // cancelled, so the copy and its rollback registration stay in one - // uninterruptible region: an interrupt landing mid-copy still waits - // for the write to settle and records the path before cleanup runs. + // Copies isolate edits from the retry source. Keep the uncancellable copy and rollback registration + // uninterruptible so cleanup always sees completed writes. yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 45bf03610316..3727df9e3753 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -67,12 +67,10 @@ export const layer: Layer.Layer< }) { const { run, rootNode, scope, providerThread, readyCheckpointOrdinals } = yield* projections.getCheckpointCaptureContext(input.threadId, input); - // A stopped run is already terminal. Its checkpoint is the rollback point - // for the message after it, so capture leaves its status alone. + // Stopped runs remain terminal; their checkpoint is the next message's rollback point. const stopped = run?.status === "interrupted" || run?.status === "cancelled"; - // The effect is at-least-once. A settled run with a checkpoint proves - // that an earlier execution committed its result. + // A checkpoint on a settled run proves this at-least-once effect already committed. if ( run !== undefined && run.checkpointId !== null && @@ -80,9 +78,7 @@ export const layer: Layer.Layer< ) { return; } - // Rollback shares this effect lane, so it can only land before a capture - // runs, e.g. while a failed capture waits to retry. The workspace now - // holds the rollback target, and the run must stay discarded. + // Rollback uses the same lane and can precede a capture retry; preserve the restored workspace and discarded run. if (run?.status === "rolled_back") { return; } @@ -128,10 +124,7 @@ export const layer: Layer.Layer< appRunOrdinal: run.ordinal, capturedAt, }); - // Match RunExecutionService: capture loaded the waiting run before - // materializing baselines. Omit delegatedCompletion so a newer cohort - // write during capture is not overwritten by this stale snapshot - // (ProjectionStore preserves the field when absent from the payload). + // Omit delegatedCompletion to preserve cohort writes made after capture loaded the run. const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; const commandId = CommandId.make(`command:effect:checkpoint.capture:${run.id}`); yield* eventSink.commitCommand({ diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index 1327e17dbdb0..5888feed349a 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -9,8 +9,7 @@ import type * as ProjectStore from "./ProjectStore.ts"; export const SHARED_WORKSPACE_RESTORE_MESSAGE = "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead."; -// A checkpoint snapshots the whole checkout. Check at command admission and -// again before provider rollback so a newly shared worktree is rejected too. +// Check isolation at admission and rollback; checkpoints restore whole checkouts, which may become shared. export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")( function* ( thread: Pick, @@ -57,10 +56,8 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi const paths = [ otherThread.worktreePath, ...other.checkpointScopes.map((candidate) => candidate.cwd), - // A failed turn can leave an errored session with a live event stream. - // A session shared across threads keeps the cwd it was opened with, - // often another thread's; each turn runs in the thread's own - // worktree or project root, which this list already covers. + // Errored sessions can still stream. Shared sessions retain another thread's opening cwd; + // turn worktrees and project roots are already covered here. ...providerContext.providerSessions .filter( (session) => diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 149c82452b60..dadbf7d15ca4 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -173,7 +173,6 @@ it.effect.each(["initial", "waiting"])( it.effect.each([ { order: "cleanup-first", restoreFiles: true }, { order: "rollback-first", restoreFiles: true }, - // A path the server would not have made is left missing, not recreated. { order: "cleanup-first", restoreFiles: false, unmanaged: true }, ])("locks rollback with %s", ({ order, restoreFiles, unmanaged = false }) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 96a5e10c9892..385de626cfdf 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -278,9 +278,7 @@ export const layer: Layer.Layer< yield* checkpoints.deleteStaleRefs({ scope, checkpoints: staleCheckpoints }); } - // Thread commands write full provider thread rows under this lock. Without - // it, a message sent during the rollback can plan against the old native - // session and commit after this, undoing the provider's fork. + // Lock against full-row thread writes that could restore the old native session after rollback. yield* threadCommands.withLock( input.threadId, Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index cf425716bcbb..934805ac30fe 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -158,8 +158,7 @@ it.effect.each(deliveries)( Effect.sync(() => { offers.push(message); nativeQueue.push(message); - // Claude Code 2.1.289's queue watcher aborts when a now-priority - // command arrives, including while Bash blocks pending reads. + // Claude Code 2.1.289 aborts on now-priority commands even while Bash blocks reads. if (nativeQueue.some((command) => command.priority === "now")) { batchAbort.abort({ kind: "interrupt" }); } @@ -383,8 +382,6 @@ it.effect.each(deliveries)( queuedRunId: queuedRun.id, targetRunId: parent.id, }); - // Promotion cancels the queued run and moves its message into the - // running run as a steer that remembers where it came from. const promoted = yield* orchestrator.getThreadProjection(threadId); assert.equal(promoted.runs.find((run) => run.id === queuedRun.id)?.status, "cancelled"); assert.equal( diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts index e3f722d45517..06475371f4ce 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts @@ -50,7 +50,6 @@ const modelSelection = { const CODEX_DRIVER = ProviderDriverKind.make("codex"); const ITEM_ID = TurnItemId.make("turn-item:command"); -/** What a client holds after applying frames, using the same shared normalizer. */ function applyFrame( state: TerminalOutputState, frame: OrchestrationV2CommandOutputFrame, @@ -150,7 +149,6 @@ const writeCommandItem = ( }); }); -/** Collect frames into a queue so the test takes them one receipt at a time. */ const subscribeFrames = (threadId: ThreadId) => Effect.gen(function* () { const hub = yield* CommandOutputHub.CommandOutputHub; @@ -181,7 +179,6 @@ layer("CommandOutputHub", (it) => { const hub = yield* CommandOutputHub.CommandOutputHub; const threadId = yield* seedThread; yield* writeCommandItem(threadId, { status: "running" }); - // Output that arrived before anyone looked, ending mid-escape and mid-redraw. yield* hub.append({ threadId, itemId: ITEM_ID, @@ -196,7 +193,6 @@ layer("CommandOutputHub", (it) => { let client = applyFrame(EMPTY_TERMINAL_OUTPUT, first); assert.equal(client.text, "\u001b[0;32mbuilding\u001b[0m\n55%"); - // Chunks that land between frames arrive together in one append. yield* TestClock.adjust("1 second"); yield* hub.append({ threadId, itemId: ITEM_ID, chunk: "1m" }); yield* hub.append({ threadId, itemId: ITEM_ID, chunk: "\r100%\u001b[0m\ndone\n" }); @@ -210,7 +206,6 @@ layer("CommandOutputHub", (it) => { ).text, ); - // The persisted item settles the row; its output is what the client keeps. yield* TestClock.adjust("1 second"); yield* writeCommandItem(threadId, { status: "completed", @@ -251,7 +246,6 @@ layer("CommandOutputHub", (it) => { const frames = yield* subscribeFrames(threadId); let client = applyFrame(EMPTY_TERMINAL_OUTPUT, yield* takeFrame(frames)); assert.equal(client.text, "abc"); - // 64 newer commands push this one's tail out of the hub. for (let index = 0; index < 64; index += 1) { yield* hub.append({ threadId, @@ -353,8 +347,7 @@ layer("CommandOutputHub", (it) => { const frames = yield* subscribeFrames(threadId); yield* takeFrame(frames); yield* TestClock.adjust("1 second"); - // Some providers stop a command without reporting its output. The session - // manager releases the live tail before the settled item is persisted. + // Some providers omit stopped-command output; the live tail is released before the settled item persists. yield* hub.finish({ threadId, itemId: ITEM_ID }); yield* writeCommandItem(threadId, { status: "interrupted" }); const final = yield* takeFrame(frames); diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.ts b/apps/server/src/orchestration-v2/CommandOutputHub.ts index 57b4b855cd9b..c3f97eff335a 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.ts @@ -26,15 +26,7 @@ import * as Stream from "effect/Stream"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -/** - * Live command output. - * - * Output never becomes an orchestration event. Adapters push raw chunks here - * (through the session manager), the hub keeps a bounded, normalized tail per - * running command, and `subscribe` serves one expanded row: the current tail, - * then coalesced appends, then the persisted final output. Memory is bounded - * per command and in count, and a client that is not showing the row receives nothing. - */ +/** Output bypasses orchestration events; expanded rows receive tails, coalesced appends, then persisted output. */ /** Fastest a subscriber receives frames while output flows. */ const COMMAND_OUTPUT_FRAME_INTERVAL_MS = 100; @@ -42,16 +34,14 @@ const COMMAND_OUTPUT_FRAME_INTERVAL_MS = 100; const COMMAND_OUTPUT_REPLACE_INTERVAL_MS = 500; /** Raw text a subscriber may accumulate before it is sent the tail instead. */ const COMMAND_OUTPUT_MAX_APPEND_CHARS = 16 * 1024; -/** Running commands tracked at once; the least recently written is dropped beyond this. */ +/** Evict the least recently written command beyond this limit. */ const MAX_LIVE_COMMANDS = 64; -/** Finished tails kept for a settle that reports no output of its own (an interrupt). */ +/** Retain finished tails for settlements that omit output. */ const MAX_FINISHED_COMMANDS = 16; -/** Final output read from persistence is normalized from at most this many trailing chars. */ const MAX_FINAL_SOURCE_CHARS = TERMINAL_OUTPUT_TAIL_CHARS * 4; interface Subscriber { pendingAppend: string; - /** The next frame must carry the whole tail. */ needsReplace: boolean; /** `needsReplace` was caused by the subscriber falling behind. */ overflowed: boolean; @@ -61,14 +51,9 @@ interface Subscriber { export type CommandOutputTarget = OrchestrationV2SubscribeCommandOutputInput; export interface CommandOutputHubShape { - /** Add raw output for a running command. Called for every provider chunk. */ readonly append: (input: CommandOutputTarget & { readonly chunk: string }) => Effect.Effect; - /** - * The command settled. Its tail is kept briefly for a settle that carries no - * output (an interrupt); otherwise the persisted output is what rows show. - */ + /** Retains the tail briefly for settlements without output; otherwise rows use persisted output. */ readonly finish: (input: CommandOutputTarget) => Effect.Effect; - /** Frames for one row until its command settles; see `OrchestrationV2CommandOutputFrame`. */ readonly subscribe: ( input: CommandOutputTarget, ) => Stream.Stream; @@ -84,7 +69,6 @@ function commandKey(target: CommandOutputTarget): string { return `${target.threadId}\u0000${target.itemId}`; } -/** Claude Bash rows stored before #15505 hold the raw `{ stdout, stderr, interrupted, ... }` result. */ const decodeLegacyClaudeBashResult = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ stdout: Schema.String, stderr: Schema.String, interrupted: Schema.Boolean }), @@ -100,7 +84,6 @@ function legacyClaudeBashOutput(output: string): string { return out.length > 0 && err.length > 0 && !out.endsWith("\n") ? `${out}\n${err}` : out + err; } -/** The output a row shows from a persisted item, bounded before normalization. */ function persistedCommandOutput(output: string | undefined): { readonly text: string; readonly truncated: boolean; @@ -108,7 +91,7 @@ function persistedCommandOutput(output: string | undefined): { if (output === undefined) return { text: "", truncated: false }; let source = output; if (output.length > MAX_FINAL_SOURCE_CHARS) { - // Start on a fresh line, so the cut can't land inside an escape sequence. + // Cut at a newline to avoid splitting escape sequences. source = output.slice(output.length - MAX_FINAL_SOURCE_CHARS); const newline = source.indexOf("\n"); if (newline !== -1 && newline + 1 < source.length) source = source.slice(newline + 1); @@ -120,7 +103,6 @@ function persistedCommandOutput(output: string | undefined): { }; } -/** Sets `key` as the newest entry of `map`, dropping the oldest beyond `max`. */ function setBounded( map: Map, key: string, @@ -139,7 +121,6 @@ function setBounded( export const make = Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const projections = yield* ProjectionStore.ProjectionStoreV2; - // Insertion order doubles as recency: every write re-inserts its entry. const tails = new Map(); const finished = new Map(); const subscribers = new Map>(); @@ -167,7 +148,7 @@ export const make = Effect.gen(function* () { const key = commandKey(target); const tail = appendTerminalOutput(tails.get(key) ?? EMPTY_TERMINAL_OUTPUT, chunk); setBounded(tails, key, tail, MAX_LIVE_COMMANDS, (oldest) => { - // Its viewers no longer share the hub's state; resync them on the next frame. + // Eviction invalidates viewers' state; resync on their next frame. for (const subscriber of subscribers.get(oldest) ?? []) { subscriber.needsReplace = true; subscriber.pendingAppend = ""; @@ -196,7 +177,7 @@ export const make = Effect.gen(function* () { overflowed: false, wake, }; - // Attach before reading anything, so no chunk falls between a read and the attach. + // Subscribe before reading to avoid losing intervening chunks. const keySubscribers = subscribers.get(key) ?? new Set(); keySubscribers.add(subscriber); subscribers.set(key, keySubscribers); @@ -209,8 +190,7 @@ export const make = Effect.gen(function* () { }), ); - // The persisted item says when the command is done and what it finally printed. - // Providers that report output only in item snapshots (ACP, Pi) stream through here too. + // Persisted items provide settlement and final output; snapshot-only providers (ACP, Pi) also stream here. let snapshotOutput: string | undefined; let lost: Cause.Cause | undefined; let settled: Extract | undefined; @@ -240,8 +220,7 @@ export const make = Effect.gen(function* () { ? observe(stored.event.payload) : Effect.void, ), - // Without its event stream the row can never settle: end with an error - // instead of waiting forever. + // A lost event stream cannot settle the row; end with an error. Effect.catchCause((cause) => Effect.sync(() => { lost = cause; @@ -250,7 +229,7 @@ export const make = Effect.gen(function* () { Effect.forkScoped, ); const item = yield* projections.getTurnItem(target); - // Only command rows have output; anything else would hold a subscription open forever. + // Non-command rows would hold output subscriptions open forever. if (item === null || item.type !== "command_execution") { return yield* new OrchestrationV2CommandOutputError({ threadId: target.threadId, @@ -280,7 +259,6 @@ export const make = Effect.gen(function* () { } lastFrameAt = yield* Clock.currentTimeMillis; throttle = COMMAND_OUTPUT_FRAME_INTERVAL_MS; - // A finished tail still shows until the settled item arrives. const tail = tails.get(key) ?? finished.get(key); if (settled !== undefined) { if (settled.output === undefined && tail !== undefined) { @@ -302,8 +280,7 @@ export const make = Effect.gen(function* () { if (tail === undefined) { return { kind: "replace", ...persistedCommandOutput(snapshotOutput), running: true }; } - // The cursor, style and any unfinished escape ride along so the client - // applies later appends exactly as the hub does. + // Include terminal state so clients interpret later appends identically. return { kind: "replace", text: terminalOutputResumeText(tail), diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 923542d877df..6c07a410a1b3 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -135,8 +135,7 @@ export function resolveMessageDispatchIntent( ): MessageDispatchMode { const mode = resolveDeliveryIntent(projection, requestedMode, deliveryIntent); if (mode.type !== "steer_active") return mode; - // Compaction and sign-out turns cannot take steering input. A message sent - // while one runs waits in the queue like any other follow-up. + // Compaction and sign-out cannot accept steers; queue follow-ups. const target = projection.runs.find((run) => run.id === mode.targetRunId); const targetMessage = projection.messages.find((message) => message.id === target?.userMessageId); return targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage) @@ -398,8 +397,7 @@ const ensureContextHandoff: CommandPolicyV2Shape["ensureContextHandoff"] = (inpu }; const decideForkExecution: CommandPolicyV2Shape["decideForkExecution"] = (input) => { - // Unsuccessful runs may have no native turn or assistant cursor. Forking - // those at native head can include later turns, so use the bounded transcript. + // Unsuccessful runs may lack native cursors; fork bounded transcripts to exclude later turns. const canForkNatively = (input.sourceRunStatus === "completed" || input.sourceRunStatus === "waiting") && input.sameProvider && diff --git a/apps/server/src/orchestration-v2/CommandReceiptStore.ts b/apps/server/src/orchestration-v2/CommandReceiptStore.ts index 981255484e73..ed8c690e5b72 100644 --- a/apps/server/src/orchestration-v2/CommandReceiptStore.ts +++ b/apps/server/src/orchestration-v2/CommandReceiptStore.ts @@ -71,11 +71,11 @@ export interface CommandReceiptStoreV2Shape { readonly upsert: ( receipt: AnyCommandReceiptV2, ) => Effect.Effect; - /** A thread command's receipt; none when the command id belongs to a project command. */ + /** None for project command ids. */ readonly getByCommandId: ( commandId: CommandId, ) => Effect.Effect, CommandReceiptStoreV2Error>; - /** A project command's receipt; none when the command id belongs to a thread command. */ + /** None for thread command ids. */ readonly getProjectByCommandId: ( commandId: CommandId, ) => Effect.Effect, CommandReceiptStoreV2Error>; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 799defe965cb..ff2ca1fbec44 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -12,11 +12,7 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; -/** - * Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. - * Run `unsent` when the provider refused the turn before reading the prompt, so that - * refusal does not leave an ambiguous marker behind. - */ +/** Persist around injection; ambiguous delivery requires a fresh native thread. Call unsent if refused before prompt consumption. */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { readonly handoffs: ReadonlyArray; @@ -132,8 +128,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH yield* persist("pending"); } if (input.deferInline) { - // Compaction APIs cannot accept an inline transcript. Keep it available - // for the next ordinary turn when native injection is unsupported. + // Compaction cannot carry inline transcripts; retain for the next turn if native injection is unsupported. yield* Effect.forEach(pending, input.persist, { discard: true }); return { context: "", delivered: Effect.void, unsent: Effect.void }; } diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index cc7ee268827f..6c536641484a 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2037,7 +2037,7 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { completionWake: "always", continuationPending: true, runStatus: "completed", - lostWork: [{ kind: "monitor", label: "Gate attempt 9" }], + lostWork: [{ kind: "monitor", label: "Gate attempt 9", awaited: true }], now, }); yield* orchestrator.recoverDelegatedTasks; @@ -2059,9 +2059,7 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); - // A running provider turn does not prove the provider consumed the delivery - // (Claude marks the turn running before it reads the prompt), so a cut - // delivery is offered again even when a continuation resumes that turn. + // Running does not prove prompt consumption in Claude; re-offer cut deliveries even when resuming the turn. it.effect("re-offers a cut delivery even when a continuation resumes its turn", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts index ed255570d8ad..8d7c8797b6e1 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -12,11 +12,7 @@ export interface DispatchModeRefusal extends DispatchModes { readonly mode: "runtime" | "interaction"; } -/** - * A caller checks its target's modes before dispatching, but the target's user can raise them - * meanwhile; the orchestrator re-checks this limit inside the thread's command lock, where nothing - * else can change them. - */ +/** Recheck target modes under the command lock; the user can raise them after preflight. */ export interface DispatchModeLimitValue extends DispatchModes { readonly refused?: Ref.Ref; } diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index a02412d5257f..7e848d17c393 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -170,12 +170,7 @@ export const OrchestrationEffectStatusV2 = Schema.Literals([ ]); export type OrchestrationEffectStatusV2 = typeof OrchestrationEffectStatusV2.Type; -/** - * Command retries are deduplicated by their receipts first, and a run's effects - * are re-enqueued only around one server restart, so a week is far longer than - * either and leaves recent history for debugging. Failed rows are kept: storage - * cleanup keeps a deleted thread's worktree while any of its effects failed. - */ +/** Receipts deduplicate retries, and restart re-enqueues need far less than a week. Retain failed rows to keep deleted-thread worktrees. */ export const SETTLED_EFFECT_RETENTION = Duration.days(7); const PRUNE_BATCH_SIZE = 500; @@ -335,8 +330,7 @@ export const layer: Layer.Layer = La available, Array.from({ length: Math.min(64, Math.max(0, Math.floor(count))) }, () => undefined), ).pipe(Effect.asVoid); - // An earlier effect waiting out a retry backoff still blocks later ones, so - // a turn cannot start while a failed rollback is about to restore files. + // Retry backoff must block later effects so turns cannot race a pending rollback. const claimableCandidatePredicate = ( availableBefore?: string, excludeRestartContinuations = false, @@ -557,9 +551,7 @@ export const layer: Layer.Layer = La const leaseExpiresAt = DateTime.formatIso( DateTime.add(now, { milliseconds: Math.max(1, leaseDurationMs) }), ); - // Empty safety claims are expected while the daemon is idle. Tracing - // this query records the full SQL text on every poll and can dominate - // the local trace without adding actionable information. + // Idle claims return empty; tracing their full SQL on every poll overwhelms useful traces. const claimStatement = sql` UPDATE orchestration_v2_effect_outbox SET @@ -710,8 +702,7 @@ export const layer: Layer.Layer = La const cutoff = DateTime.formatIso( DateTime.subtractDuration(yield* DateTime.now, SETTLED_EFFECT_RETENTION), ); - // Each batch is its own statement, so a large backlog never holds the - // write lock for long and writers can commit between batches. + // Separate batch statements release the write lock between batches. const deleteBatch = sql<{ readonly effect_id: string }>` DELETE FROM orchestration_v2_effect_outbox WHERE rowid IN ( diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 8c002b72d07b..89bcb4b5844c 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -9,6 +9,7 @@ import { ProviderTurnId, RunAttemptId, RunId, + RuntimeRequestId, ThreadId, VcsProcessExitError, } from "@t3tools/contracts"; @@ -84,6 +85,7 @@ function layerExecutorFor(input: { readonly continueAfterRestart?: boolean; readonly interrupt?: ProviderTurnControlService.ProviderTurnControlServiceV2Shape["interrupt"]; readonly rollback?: CheckpointRollbackService.CheckpointRollbackServiceV2Shape["execute"]; + readonly respond?: RuntimeRequestService.RuntimeRequestServiceV2["Service"]["respond"]; }) { const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); const layerDependencies = Layer.mergeAll( @@ -143,7 +145,9 @@ function layerExecutorFor(input: { ), Layer.succeed( RuntimeRequestService.RuntimeRequestServiceV2, - RuntimeRequestService.RuntimeRequestServiceV2.of({ respond: () => Effect.void }), + RuntimeRequestService.RuntimeRequestServiceV2.of({ + respond: input.respond ?? (() => Effect.void), + }), ), Layer.succeed( ThreadTitleRegenerationService.ThreadTitleRegenerationService, @@ -270,6 +274,42 @@ it.effect.each([ }), ); +it.effect("does not report an answer whose last delivery attempt is interrupted", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const reported = yield* Ref.make(0); + const layer = layerExecutorFor({ + events: yield* Ref.make>([]), + respond: () => Effect.interrupt, + threads: { + dispatch: (command) => + Ref.update(reported, (count) => + command.type === "runtime-request.delivery.fail" ? count + 1 : count, + ).pipe(Effect.as({ sequence: 1, storedEvents: [] })), + }, + }); + const exit = yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + return yield* Effect.exit( + executor.execute( + { + ...restartEffect(now, { type: "detach" }), + request: { + type: "runtime-request.respond", + providerSessionId: oldSessionId, + requestId: RuntimeRequestId.make("runtime-request:effect-worker-interrupted"), + answers: { next: "Ship it" }, + }, + }, + { willRetry: false }, + ), + ); + }).pipe(Effect.provide(layer)); + assert.isTrue(Exit.hasInterrupts(exit)); + assert.equal(yield* Ref.get(reported), 0); + }), +); + it.effect("requeues a claim when a pre-execution worker check fails", () => Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index d7898a7d7c4f..e820420456f5 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -264,8 +264,7 @@ export const layerExecutor: Layer.Layer< const message = projection.messages.find((item) => item.id === messageId); const run = projection.runs.find((item) => item.id === message?.runId); if (message === undefined || run === undefined) return yield* error; - // Reuse the message identity and a stable command receipt so an outbox - // retry cannot append a duplicate message or start a second follow-up. + // Reuse message ids and receipts so retries cannot duplicate messages or follow-ups. yield* threads .dispatch({ type: "message.dispatch", @@ -300,11 +299,7 @@ export const layerExecutor: Layer.Layer< : { senderThreadId: message.senderThreadId }), }) .pipe( - // The turn that just completed leaves no further retry - // target once the instance is off: the completed turn - // stands as the thread's result instead of going - // through the outbox's bounded retry on a refusal that - // retrying cannot change. + // A disabled instance cannot retry this completed turn; its result stands. Effect.catchTags({ ThreadManagementProviderInstanceDisabledError: (disabledError) => Effect.logInfo( @@ -376,7 +371,8 @@ export const layerExecutor: Layer.Layer< }), ), ); - case "runtime-request.respond": + case "runtime-request.respond": { + const requestId = effect.request.requestId; return runtimeRequests .respond({ threadId: effect.threadId, @@ -393,6 +389,25 @@ export const layerExecutor: Layer.Layer< : { answers: effect.request.answers }), }) .pipe( + Effect.tapCause((cause) => + willRetry || Cause.hasInterruptsOnly(cause) + ? Effect.void + : threads + .dispatch({ + type: "runtime-request.delivery.fail", + commandId: CommandId.make(`${effect.commandId}:delivery-failed`), + threadId: effect.threadId, + requestId, + }) + .pipe( + Effect.catchCause((recordCause) => + Effect.logWarning("Failed to record answer delivery failure", { + effectId: effect.id, + cause: recordCause, + }), + ), + ), + ), Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ @@ -402,6 +417,7 @@ export const layerExecutor: Layer.Layer< }), ), ); + } case "provider-thread.rollback": return checkpointRollback .execute({ @@ -704,10 +720,7 @@ export const layerWithOptions = ( return false; } const effect = claimed.value; - // Arm the process-local cancellation signal before re-reading durable - // state. A cancellation that commits after the row read has begun can - // then still win the execution race instead of falling into the gap - // between the read and signal registration. + // Arm cancellation before rereading durable state to close the read-to-registration race. const cancellation = outbox .awaitCancellation(effect.id) .pipe(Effect.as("cancelled" as const)); @@ -724,10 +737,7 @@ export const layerWithOptions = ( ), Duration.millis(Math.max(0, claimedAt - eligibleAt)), ); - // Cancellation can commit after the durable claim but before the - // process-local Deferred is registered. Re-read the authoritative row - // once before starting external work; later cancellations use the - // Deferred raced below. + // Recheck durable cancellation after registering the Deferred; cancellation may have committed after claim. if (yield* wasCancelled(effect.id)) { yield* outbox.clearCancellation(effect.id); return true; @@ -856,9 +866,7 @@ export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions = const livenessPollIntervalMs = Number.isFinite(requestedLivenessPollIntervalMs) ? Math.max(1, Math.floor(requestedLivenessPollIntervalMs)) : DEFAULT_EFFECT_WORKER_LIVENESS_POLL_INTERVAL_MS; - // Post-commit notifications are the low-latency path. `availableAt` is the - // durable retry schedule, and the long liveness poll only recovers from a - // missed in-process notification or work inserted by another process. + // Notifications wake promptly; availableAt persists retry timing, and liveness polling recovers missed wakeups. const runWorker = Effect.gen(function* () { while (true) { const outcome = yield* worker.runOnce.pipe( @@ -874,9 +882,7 @@ export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions = continue; } if (outcome === "failed") { - // A due row can remain visible when a claim UPDATE fails. Do not - // feed that past deadline back into the scheduler and retry at the - // one-millisecond floor; let transient database failures cool off. + // Failed claims can leave overdue rows; cool off instead of rescheduling at the one-millisecond floor. yield* Effect.sleep(Duration.millis(Math.min(1_000, livenessPollIntervalMs))); continue; } diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 3e3d4b65e357..4d28ead452d4 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -108,12 +108,7 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; - /** - * Atomically commit only when the provider thread is still owned by the - * expected run attempt and ordinal. Used for late post-terminal - * provider_thread updates so a completed or superseded attempt cannot clobber - * a newer attempt that already claimed the thread. - */ + /** Commits only if the expected attempt and ordinal still own the provider thread, protecting newer attempts. */ readonly writeIfProviderThreadOwner: (input: { readonly guardPendingUserInputCancellations?: boolean; readonly commandId?: CommandId; @@ -129,12 +124,7 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; - /** - * Atomically commit only while the stored provider thread still equals - * `expected`, the row its writer read, so a read-modify-write of the row - * cannot overwrite an update published in between. The whole row is - * compared because not every writer moves `updatedAt`. - */ + /** Commits only if the full provider row equals expected; some writers do not advance updatedAt. */ readonly writeIfProviderThreadUnchanged: (input: { readonly expected: OrchestrationV2ProviderThread; readonly events: ReadonlyArray; @@ -259,10 +249,7 @@ const layerBase: Layer.Layer< Effect.andThen(publishLiveEvents(events)), ); - // Transactions commit one at a time, but each writer publishes after its - // commit. If a writer is descheduled in between, a later commit reaches - // subscribers first, and clients drop any event at or below the newest - // sequence they have applied. + // Serialize commit and publish; reordered publication makes clients drop earlier sequences. const publishLane = yield* Semaphore.make(1); const commitThenPublish = ( transaction: Effect.Effect, @@ -997,10 +984,7 @@ const layerBase: Layer.Layer< }), ); -/** - * Keeping the outbox instance shared with the worker is important because enqueue notifications - * are in-memory wakeups backed by the durable SQL queue. - */ +/** Share the worker's outbox instance: enqueue wakeups are in-memory. */ export const layerFromStores = layerBase; export const layer: Layer.Layer< diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index a37bcf8f2cd8..0cc9fdbe3d06 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -804,9 +804,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { `; const summary = yield* maintenance.compactEventStore; - // Superseded state spans several discovery pages. Both turn-item updates - // stay, and so do the first message and node events (replay order). The - // rename stays too: visits are read state and only supersede each other. assert.isAtLeast(summary.deletedEventCount, 504); assert.isAtLeast(summary.deletedReceiptCount, 1); diff --git a/apps/server/src/orchestration-v2/GuestShare.test.ts b/apps/server/src/orchestration-v2/GuestShare.test.ts new file mode 100644 index 000000000000..d789b206016b --- /dev/null +++ b/apps/server/src/orchestration-v2/GuestShare.test.ts @@ -0,0 +1,343 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + GuestMessageId, + GuestShareId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for guest shares"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; +const layerDatabase = SqlitePersistence.layerMemory; +const layerTest = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "guest-share" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +); + +const threadId = ThreadId.make("thread:guest-share"); +const shareId = GuestShareId.make("share:one"); +const guestMessageId = GuestMessageId.make("guest-message:one"); + +const setup = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-guest-share"), + threadId, + projectId: ProjectId.make("project:guest-share"), + title: "Review me", + modelSelection: { instanceId, model: "gpt-5.1-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-share.open", + commandId: CommandId.make("open-guest-share"), + threadId, + shareId, + host: "env.t3.test", + expiresAt: DateTime.formatIso(DateTime.add(yield* DateTime.now, { hours: 24 })), + }); + return orchestrator; +}); + +const sendGuestMessage = (orchestrator: Orchestrator.OrchestratorV2["Service"], id: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(id), + guestMessageId, + text: "ignored: the server sends the guest's own text", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + +it.effect("redeems a guest link once and sends each guest message once, with its turn", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const redeem = (id: string) => + orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make(id), + threadId, + shareId, + guestName: "Sam", + }); + yield* redeem("redeem-1"); + assert.isTrue(Exit.isFailure(yield* Effect.exit(redeem("redeem-2")))); + + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit-1"), + threadId, + shareId, + guestMessageId, + text: "Why not keep the old cache?", + }); + assert.equal((yield* projections.getThreadShell(threadId))?.pendingGuestMessageCount, 1); + + yield* sendGuestMessage(orchestrator, "send-1"); + const projection = yield* projections.getThreadProjection(threadId); + assert.deepEqual( + projection.thread.guestShare?.messages.map((message) => message.status), + ["sent"], + ); + const sent = projection.messages.find((message) => message.id === MessageId.make("send-1")); + assert.equal( + sent?.text, + "Message from guest Sam, sent by the owner:\n\nWhy not keep the old cache?", + ); + assert.deepEqual(sent?.context?.guest, { + guestMessageId, + name: "Sam", + text: "Why not keep the old cache?", + shareId, + }); + assert.lengthOf(projection.runs, 1); + assert.equal((yield* projections.getThreadShell(threadId))?.pendingGuestMessageCount, 0); + + // A second owner device clicking Send must not start another turn. + assert.isTrue(Exit.isFailure(yield* Effect.exit(sendGuestMessage(orchestrator, "send-2")))); + assert.lengthOf((yield* projections.getThreadProjection(threadId)).runs, 1); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("rejects guest messages until the link is redeemed and after archive", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const submit = orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit-early"), + threadId, + shareId, + guestMessageId, + text: "Hello", + }); + assert.isTrue(Exit.isFailure(yield* Effect.exit(submit))); + + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-guest-share"), + threadId, + }); + assert.isNull((yield* projections.getThread(threadId)).guestShare ?? null); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("drops guest attribution that a client sends on its own message", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("owner-message"), + threadId, + messageId: MessageId.make("owner-message"), + text: "Mine", + context: { version: 1, records: [], guest: { guestMessageId, name: "Sam", text: "Mine" } }, + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const projection = yield* projections.getThreadProjection(threadId); + const sent = projection.messages.find( + (message) => message.id === MessageId.make("owner-message"), + ); + assert.isUndefined(sent?.context?.guest); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("keeps a queued guest message unchanged when the owner tries to edit it", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("owner-first"), + threadId, + messageId: MessageId.make("owner-first"), + text: "Start", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make("redeem"), + threadId, + shareId, + guestName: "Sam", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit"), + threadId, + shareId, + guestMessageId, + text: "Why?", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("send-queued"), + threadId, + messageId: MessageId.make("send-queued"), + guestMessageId, + text: "", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* projections.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + ); + assert.isDefined(queued); + + const edit = orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("edit"), + threadId, + runId: queued.id, + text: "My own words", + }); + assert.isTrue(Exit.isFailure(yield* Effect.exit(edit))); + const message = (yield* projections.getThreadProjection(threadId)).messages.find( + (candidate) => candidate.id === MessageId.make("send-queued"), + ); + assert.equal(message?.context?.guest?.text, "Why?"); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("shows a guest message as dismissed when the owner cancels its queued send", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("owner-first"), + threadId, + messageId: MessageId.make("owner-first"), + text: "Start", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make("redeem"), + threadId, + shareId, + guestName: "Sam", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit"), + threadId, + shareId, + guestMessageId, + text: "Why?", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("send-queued"), + threadId, + messageId: MessageId.make("send-queued"), + guestMessageId, + text: "", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* projections.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + ); + assert.isDefined(queued); + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel"), + threadId, + runId: queued.id, + }); + const { thread } = yield* projections.getThreadProjection(threadId); + assert.equal( + thread.guestShare?.messages.find((message) => message.id === guestMessageId)?.status, + "dismissed", + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("brings a settled thread back when its guest sends a request", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make("redeem"), + threadId, + shareId, + guestName: "Sam", + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("settle"), + threadId, + }); + assert.equal( + (yield* projections.getThreadProjection(threadId)).thread.settledOverride, + "settled", + ); + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit"), + threadId, + shareId, + guestMessageId, + text: "Why?", + }); + const { thread } = yield* projections.getThreadProjection(threadId); + assert.isNull(thread.settledOverride); + assert.isNull(thread.settledAt); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/GuestShareDecider.ts b/apps/server/src/orchestration-v2/GuestShareDecider.ts new file mode 100644 index 000000000000..00f9f8ae3b9d --- /dev/null +++ b/apps/server/src/orchestration-v2/GuestShareDecider.ts @@ -0,0 +1,255 @@ +import { + GUEST_PENDING_ATTACHMENT_BYTES_LIMIT, + GUEST_PENDING_MESSAGE_LIMIT, + GUEST_RESOLVED_MESSAGE_LIMIT, + type GuestMessageAttribution, + type GuestMessageId, + type OrchestrationMessageContext, + type OrchestrationV2AppThread, + type OrchestrationV2ServerCommand, + type ThreadGuestMessage, + type ThreadGuestShare, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Result from "effect/Result"; + +export type GuestShareCommand = Extract< + OrchestrationV2ServerCommand, + { + readonly type: + | "thread.guest-share.open" + | "thread.guest-share.redeem" + | "thread.guest-share.close" + | "thread.guest-message.submit" + | "thread.guest-message.dismiss"; + } +>; + +/** Failures a guest may be told about. Any other dispatch failure reaches them as "rejected". */ +export const GUEST_FAILURE = { + inactive: "This guest link is no longer active.", + used: "This guest link was already used.", + busy: "Wait for the owner to answer your earlier messages.", + filesBusy: "Wait for the owner to answer your earlier messages before you attach more files.", +} as const; + +const isExpired = (share: ThreadGuestShare, now: DateTime.Utc) => + DateTime.toEpochMillis(now) >= Date.parse(share.expiresAt); + +const pendingAttachments = (share: ThreadGuestShare | null) => + share?.messages.flatMap((message) => + message.status === "pending" ? (message.attachments ?? []) : [], + ) ?? []; + +/** + * Stored files a share change leaves behind: those of waiting messages that were dismissed or + * dropped with the share. A sent message keeps its files, which its thread message now holds. + */ +export function releasedGuestAttachmentIds( + before: ThreadGuestShare | null, + after: ThreadGuestShare | null, +): ReadonlyArray { + const kept = new Set( + after !== null && after.id === before?.id + ? after.messages.flatMap((message) => + message.status === "dismissed" + ? [] + : (message.attachments ?? []).map((attachment) => attachment.id), + ) + : [], + ); + return pendingAttachments(before) + .map((attachment) => attachment.id) + .filter((id) => !kept.has(id)); +} + +/** Keeps every pending message and only the newest resolved ones. */ +function trimResolved(messages: ReadonlyArray): Array { + let resolvedBudget = GUEST_RESOLVED_MESSAGE_LIMIT; + const kept: Array = []; + for (const message of messages.toReversed()) { + if (message.status !== "pending") { + if (resolvedBudget === 0) continue; + resolvedBudget -= 1; + } + kept.push(message); + } + return kept.toReversed(); +} + +const resolveMessage = ( + share: ThreadGuestShare, + guestMessageId: GuestMessageId, + status: "sent" | "dismissed", +): Result.Result => { + const message = share.messages.find((candidate) => candidate.id === guestMessageId); + if (message === undefined || message.status !== "pending") { + return Result.fail(`Guest message ${guestMessageId} is not pending.`); + } + return Result.succeed({ + ...share, + messages: trimResolved( + share.messages.map((candidate) => + candidate.id === guestMessageId ? { ...candidate, status } : candidate, + ), + ), + }); +}; + +/** + * Applies a guest share command to the thread. The orchestrator runs commands one at a time, so + * checking state here is what makes a link redeem once and a message send once. + */ +export function decideGuestShareCommand(input: { + readonly thread: OrchestrationV2AppThread; + readonly command: GuestShareCommand; + readonly now: DateTime.Utc; +}): Result.Result { + const { thread, command, now } = input; + const share = thread.guestShare ?? null; + const withShare = (guestShare: ThreadGuestShare | null) => + Result.succeed({ ...thread, guestShare, updatedAt: now }); + + switch (command.type) { + case "thread.guest-share.open": { + if (thread.archivedAt !== null) return Result.fail("Archived threads cannot be shared."); + return withShare({ + id: command.shareId, + host: command.host, + createdAt: DateTime.formatIso(now), + expiresAt: command.expiresAt, + guestName: null, + messages: [], + }); + } + case "thread.guest-share.close": + return share === null ? Result.succeed(thread) : withShare(null); + case "thread.guest-share.redeem": { + if (share === null || share.id !== command.shareId || isExpired(share, now)) { + return Result.fail(GUEST_FAILURE.inactive); + } + if (share.guestName !== null) return Result.fail(GUEST_FAILURE.used); + return withShare({ ...share, guestName: command.guestName }); + } + case "thread.guest-message.submit": { + if ( + share === null || + share.id !== command.shareId || + share.guestName === null || + isExpired(share, now) + ) { + return Result.fail(GUEST_FAILURE.inactive); + } + if ( + share.messages.filter((message) => message.status === "pending").length >= + GUEST_PENDING_MESSAGE_LIMIT + ) { + return Result.fail(GUEST_FAILURE.busy); + } + const attachments = command.attachments ?? []; + const pendingBytes = [...pendingAttachments(share), ...attachments].reduce( + (total, attachment) => total + attachment.sizeBytes, + 0, + ); + if (attachments.length > 0 && pendingBytes > GUEST_PENDING_ATTACHMENT_BYTES_LIMIT) { + return Result.fail(GUEST_FAILURE.filesBusy); + } + const guestShare: ThreadGuestShare = { + ...share, + messages: trimResolved([ + ...share.messages, + { + id: command.guestMessageId, + text: command.text, + status: "pending", + submittedAt: DateTime.formatIso(now), + ...(attachments.length === 0 ? {} : { attachments }), + }, + ]), + }; + // A request needs the owner, so a settled thread comes back as a new message brings it. + return thread.settledOverride === "settled" + ? Result.succeed({ + ...thread, + guestShare, + settledOverride: null, + settledAt: null, + unsettledAt: now, + updatedAt: now, + }) + : withShare(guestShare); + } + case "thread.guest-message.dismiss": { + if (share === null) return Result.fail("This thread has no guest link."); + return Result.map(resolveMessage(share, command.guestMessageId, "dismissed"), (next) => ({ + ...thread, + guestShare: next, + updatedAt: now, + })); + } + } +} + +/** + * Marks a guest message sent. `text` is what the agent receives: it names the guest so the agent + * does not mistake it for the owner's words, and so a guest cannot start a slash command. + * `guest` lets clients show the guest's own text with their name beside it. + */ +export function decideGuestMessageSend(input: { + readonly thread: OrchestrationV2AppThread; + readonly guestMessageId: GuestMessageId; + readonly now: DateTime.Utc; +}): Result.Result< + { + readonly thread: OrchestrationV2AppThread; + readonly text: string; + readonly guest: GuestMessageAttribution; + }, + string +> { + const share = input.thread.guestShare ?? null; + if (share === null) return Result.fail("This thread has no guest link."); + const message = share.messages.find((candidate) => candidate.id === input.guestMessageId); + const name = share.guestName ?? "Guest"; + const guestText = message?.text ?? ""; + return Result.map(resolveMessage(share, input.guestMessageId, "sent"), (next) => ({ + thread: { ...input.thread, guestShare: next, updatedAt: input.now }, + text: `Message from guest ${name}, sent by the owner:\n\n${guestText}`, + guest: { guestMessageId: input.guestMessageId, name, text: guestText, shareId: share.id }, + })); +} + +/** + * The owner cancelled a queued send, so the agent never got the message. It shows as dismissed, + * which the guest can see, instead of sent with no reply ever coming. A share that changed since + * the send is left alone. + */ +export function guestShareAfterCancelledSend( + thread: OrchestrationV2AppThread, + guest: Pick, + now: DateTime.Utc, +): OrchestrationV2AppThread { + const share = thread.guestShare ?? null; + if (share === null || share.id !== guest.shareId) return thread; + const message = share.messages.find((candidate) => candidate.id === guest.guestMessageId); + if (message?.status !== "sent") return thread; + return { + ...thread, + guestShare: { + ...share, + messages: share.messages.map((candidate) => + candidate.id === guest.guestMessageId ? { ...candidate, status: "dismissed" } : candidate, + ), + }, + updatedAt: now, + }; +} + +/** Guest attribution comes only from `decideGuestMessageSend`; a client cannot claim it. */ +export function withoutGuestAttribution< + C extends { readonly context?: OrchestrationMessageContext | undefined }, +>(command: C): C { + if (command.context?.guest === undefined) return command; + const { guest: _guest, ...context } = command.context; + return { ...command, context }; +} diff --git a/apps/server/src/orchestration-v2/IdleCompactionService.ts b/apps/server/src/orchestration-v2/IdleCompactionService.ts index 30c81ba70332..2b16cb8c1d73 100644 --- a/apps/server/src/orchestration-v2/IdleCompactionService.ts +++ b/apps/server/src/orchestration-v2/IdleCompactionService.ts @@ -1,5 +1,6 @@ import { CommandId, + IDLE_COMPACTION_TEXT, MessageId, resolveIdleCompaction, resolveProjectProviderInstanceEnabled, @@ -70,10 +71,11 @@ export const make = Effect.gen(function* () { commandId: CommandId.make(`server:idle-compact:${threadId}:${runId}`), messageId: MessageId.make(`idle-compact:${threadId}:${runId}`), threadId, - text: "/compact", + text: IDLE_COMPACTION_TEXT, attachments: [], createdBy: "agent", creationSource: "server", + idleCompaction: "beforeCacheExpires", dispatchMode: { type: "start_immediately" }, }); return; diff --git a/apps/server/src/orchestration-v2/LiveStreamBudget.ts b/apps/server/src/orchestration-v2/LiveStreamBudget.ts index c4ace088c574..39ddf5509a89 100644 --- a/apps/server/src/orchestration-v2/LiveStreamBudget.ts +++ b/apps/server/src/orchestration-v2/LiveStreamBudget.ts @@ -23,8 +23,7 @@ export interface RetainedLiveItem { readonly serializedBytes: number; } -// Published events are immutable and shared across subscriptions. Measure each -// object once without keeping the event or its serialized copy alive. +// Immutable events are shared across subscribers; cache sizes without retaining events or serialized copies. const serializedSizes = new WeakMap(); function serializedSize(value: object): number { @@ -154,8 +153,7 @@ export const makeLiveStreamBudget = Effect.fn("makeLiveStreamBudget")(function* Effect.catchTags({ LiveStreamBufferError: (error) => Effect.sync(() => { - // A grouped source can retain its own pending chunk. Close it - // and release the pull closure without waiting for an RPC ACK. + // Release retained source chunks without waiting for an RPC ACK. source.pull = Effect.interrupt; }).pipe( Effect.andThen(Scope.close(sourceScope, Exit.fail(error))), @@ -176,8 +174,7 @@ export const makeLiveStreamBudget = Effect.fn("makeLiveStreamBudget")(function* ); // @effect-diagnostics-next-line returnEffectInGen:off - Stream.fromPull needs the pull effect as its result. return Effect.gen(function* () { - // RpcServer requests the next batch only after the client ACKs this - // one. Removing items from a queue alone does not mean delivery ended. + // RpcServer requests the next batch after ACK; dequeueing does not end delivery. release(inFlight); inFlight = []; yield* check; @@ -366,8 +363,7 @@ export const replayAndBufferProjectedLiveEvents = < const subscription = yield* input.subscribe.pipe(Scope.provide(subscriptionScope)); let throughSequence = input.afterSequence ?? 0; const live = yield* retainLiveStream( - // Taking one event at a time avoids retaining an uncharged PubSub batch - // while the drain charges events to the budget. + // Take one event at a time so no uncharged PubSub batch is retained. Stream.fromEffectRepeat(PubSub.take(subscription)).pipe( Stream.filter( (event) => event.sequence > throughSequence && (input.filter?.(event) ?? true), diff --git a/apps/server/src/orchestration-v2/NotificationMailbox.ts b/apps/server/src/orchestration-v2/NotificationMailbox.ts index 2afee9749e22..29ba40c5777f 100644 --- a/apps/server/src/orchestration-v2/NotificationMailbox.ts +++ b/apps/server/src/orchestration-v2/NotificationMailbox.ts @@ -16,10 +16,7 @@ export function isBufferedProviderNotification( ); } -/** - * Delivery is at least once: provider acceptance and our receipt cannot commit - * atomically. Reusing the message ID keeps recovery from duplicating timeline items. - */ +/** Acceptance and receipts cannot commit atomically; reuse message ids for at-least-once delivery. */ export function isUndeliveredMailboxSteer( projection: Pick, messageId: MessageId, diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 187c25935a07..eea36ac3b769 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -60,10 +60,7 @@ const event = (type: string, data: Record): ProviderReplayEntry }); const labelled = (entry: ProviderReplayEntry, label: string): ProviderReplayEntry => entry.type === "runtime_exit" ? entry : { ...entry, label }; -/** - * T3's own rules after a mode's: every thread's T3 MCP server is denied, and - * then this thread's own is allowed again (last match wins). - */ +/** MCP rules are last-match-wins; allow this thread after denying all T3 servers. */ const mcpRules = (name: string) => [ { action: "t3-code-*", resource: "*", effect: "deny" }, { action: `t3-code-thread_${name}_*`, resource: "*", effect: "allow" }, @@ -856,10 +853,7 @@ describe("OpenCode 2 through the orchestrator", () => { "settles a background subagent whose report OpenCode delivers into the launching turn", () => Effect.gen(function* () { - // The recorded background run, reordered as 2.0.18 ran it live when the - // child ended first: the parent's execution is still answering when the - // child's report is queued and delivered into it, and that one - // execution then answers the report and ends. + // OpenCode 2.0.18 can deliver a child report into the parent's still-running execution, which then answers it. const cwd = yield* checkpointWorkspace("opencode2-background-in-turn"); const recorded = yield* readProviderReplayTranscript( diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index f22c4e57f347..d9b5b0bccf18 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1331,7 +1331,8 @@ it.effect("replays a thread.create receipt without changing the thread", () => const before = yield* projections.getThread(threadId); const replayed = yield* orchestrator.dispatch(create); assert.equal(replayed.sequence, original.sequence); - assert.deepEqual(replayed, original); + // Marked so client analytics do not count the retry again. + assert.deepEqual(replayed, { ...original, replayed: true }); assert.deepEqual(yield* projections.getThread(threadId), before); assert.equal(yield* orchestrator.getThreadEventSequence(threadId), original.sequence); }).pipe(Effect.provide(layerTest)), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ee8e186231b9..067378918d94 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,4 +1,5 @@ import type { + GuestShareId, OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, OrchestrationV2ThreadHistoryPage, @@ -9,17 +10,19 @@ import { runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, threadPullRequestKeysEqual, legacyThreadPullRequestKey, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type ChatAttachment, CommandId, + type GuestMessageId, isOrchestrationV2WorkActive, isProviderNativeSubagentThread, MessageId, @@ -61,9 +64,10 @@ import { RuntimeMode, ThreadLinkedPullRequest, ThreadId, - type TurnItemId, + TurnItemId, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; import { derivePendingBackgroundWork, pendingBackgroundTurnItems, @@ -80,6 +84,7 @@ import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -107,6 +112,14 @@ import { isUndeliveredMailboxSteer, } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; +import { + decideGuestMessageSend, + guestShareAfterCancelledSend, + releasedGuestAttachmentIds, + withoutGuestAttribution, + decideGuestShareCommand, + type GuestShareCommand, +} from "./GuestShareDecider.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import { SESSION_BOUND_EFFECT_TYPES, @@ -309,6 +322,8 @@ export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; export interface OrchestratorV2DispatchResult { readonly sequence: number; readonly storedEvents: ReadonlyArray; + /** The command id was already accepted; analytics skip it. */ + readonly replayed?: true; } export interface OrchestratorV2Shape { @@ -320,9 +335,7 @@ export interface OrchestratorV2Shape { taskId?: NodeId, ) => Effect.Effect; readonly restartContinuationPending: (run: OrchestrationV2Run) => Effect.Effect; - /** - * A declined one records no thread event, so a waiter needs this to wake. - */ + /** Declined continuations emit no thread event; this still wakes waiters. */ readonly awaitRestartContinuation: (run: OrchestrationV2Run) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2ServerCommand, @@ -399,6 +412,9 @@ export interface OrchestratorV2Shape { readonly getThreadShell: ( threadId: ThreadId, ) => Effect.Effect; + readonly findGuestShareThreadId: ( + shareId: GuestShareId, + ) => Effect.Effect; readonly getThreadEventSequence: ( threadId: ThreadId, ) => Effect.Effect; @@ -420,10 +436,7 @@ function nextRunOrdinal(projection: Pick Math.max(max, run.ordinal), 0) + 1; } -/** - * Stamp it when the wake run starts, not when it queues: a queued prompt ahead - * of it has no start yet, and delegated results jump the queue. - */ +/** Stamp at wake start; queued prompts lack start times and delegated results jump the queue. */ function wakeWorkStartedAt( runs: ReadonlyArray, trigger: { @@ -463,6 +476,11 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.archive": case "thread.unarchive": case "thread.delete": + case "thread.guest-share.open": + case "thread.guest-share.redeem": + case "thread.guest-share.close": + case "thread.guest-message.submit": + case "thread.guest-message.dismiss": case "thread.settle": case "thread.auto-settle": case "thread.unsettle": @@ -502,6 +520,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "queued-run.cancel": case "queued-run.edit": case "runtime-request.respond": + case "runtime-request.delivery.fail": case "thread.user-input.dismiss": case "checkpoint.rollback": case "checkpoint.rollback.fail": @@ -567,6 +586,8 @@ function delegatedTasksStopEffect(command: { } const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; +const RUNTIME_REQUEST_DELIVERY_FAILED_MESSAGE = + "T3 Code could not confirm that your answer reached the agent. If the run stays stuck, stop it and try again."; // Turn item types a live provider process owns and reports the end of itself // (RunExecutionService/ProviderRuntimeRecoveryService background-work sweeps). @@ -607,11 +628,7 @@ function isBlockingRun(run: OrchestrationV2Run): boolean { ); } -/** - * A parent thread is "live" for wake purposes while a run is still producing - * agent output. A run parked at "waiting" is post-terminal drain, so its agent - * turn is over and a wake is still needed. - */ +/** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ function hasLiveRun(projection: Pick): boolean { return projection.runs.some( (run) => run.status === "preparing" || run.status === "starting" || run.status === "running", @@ -640,7 +657,7 @@ function legacyPullRequestLink( return withPullRequestWatch( { ...key, - url: linked.url, + url: canonicalizeGitCafePullRequestUrl(linked.url), source: "manual", linkedAt: DateTime.formatIso(now), snapshot: null, @@ -1064,8 +1081,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return withId; }); - // This runs inside the write transaction for every command, so finished leaf - // nodes of finished runs stay unread: busy threads hold thousands of them. + // Runs in every command transaction; avoid reading thousands of finished leaf nodes. const readCommandProjection = (threadId: ThreadId) => projectionStore .getThreadRecords( @@ -1472,8 +1488,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (usageLimitHoldsQueue(projection, queuedRun.providerInstanceId)) { return; } - // A message queued behind a worktree that was cancelled or never created - // would run in the project checkout instead, so it waits for the user. + // Failed worktree creation would run queued work in the project checkout; wait for the user. const failedRun = latestExecutedRun(projection.runs); const failureClass = failedRun?.id === options?.failedRunId @@ -1520,11 +1535,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); return; } - // Run ordinals order the transcript, checkpoints, and the latest run. A - // run that started while this one waited (a resume of a held queue, a - // reorder, a delivery that jumps the queue) has a newer ordinal, so this - // run moves after it. A started run has its prompt in the transcript (a - // wake's prompt is a notification); a message cancelled from the queue has none. + // Move queued ordinals after runs that started while they waited to preserve transcript and checkpoint order. + // Started runs have prompts; cancelled queued messages do not. const newerRunIds = projection.runs .filter((run) => run.status !== "queued" && run.ordinal > queuedRun.ordinal) .map((run) => run.id); @@ -1899,6 +1911,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.senderThreadId === undefined ? {} : { senderThreadId: queuedMessage.senderThreadId }), + ...(queuedMessage.idleCompaction === undefined + ? {} + : { idleCompaction: queuedMessage.idleCompaction }), }), providerThreadId: queuedProviderThread.id, inputIntent: "queued_turn", @@ -2590,6 +2605,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const movesForward = thread.lastVisitedAt === null || DateTime.toEpochMillis(visitedAt.value) > DateTime.toEpochMillis(thread.lastVisitedAt); + const occurredAt = yield* DateTime.now; + // Opens are timed by the server clock so devices with skewed clocks agree. + const opens = command.opened + ? recordThreadOpen( + thread.opens ?? null, + DateTime.toEpochMillis(occurredAt), + command.searchQuery, + ) + : null; // Viewing a thread changes read state only. Loading its transcript (or // bumping updatedAt) makes a routine read receipt scale with its history. yield* emit( @@ -2599,13 +2623,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "thread.visited", threadId: command.threadId, providerInstanceId: thread.providerInstanceId, - occurredAt: yield* DateTime.now, - payload: movesForward ? { ...thread, lastVisitedAt: visitedAt.value } : thread, + occurredAt, + payload: { + ...thread, + ...(movesForward ? { lastVisitedAt: visitedAt.value } : {}), + ...(opens ? { opens } : {}), + }, }); }); - // Checked under the thread lock: the watch or the thread can change while the host is read. - // The watch is recorded first so the wake's own thread events carry it. + // Recheck under the lock after reading the host. Record the watch first so wake events carry it. const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( function* ( command: Extract< @@ -2660,6 +2687,100 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + // Deleted after the share change commits: the files of waiting messages it dismissed or dropped. + const releaseGuestAttachments = ( + command: { readonly commandId: CommandId; readonly threadId: ThreadId }, + before: OrchestrationV2AppThread["guestShare"], + after: OrchestrationV2AppThread["guestShare"], + effects: Ref.Ref>, + ) => { + const attachmentIds = releasedGuestAttachmentIds(before ?? null, after ?? null); + return attachmentIds.length === 0 + ? Effect.void + : Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:guest-attachment.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "attachment.cleanup", attachmentIds: [...attachmentIds] }, + } satisfies PendingOrchestrationEffectV2, + ]); + }; + + const dispatchGuestShare = Effect.fn("orchestrationV2.dispatch.guestShare")(function* ( + command: GuestShareCommand, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, []) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const thread = projection.thread; + const now = yield* DateTime.now; + const decided = + thread.deletedAt !== null + ? Result.fail(`Thread ${command.threadId} is deleted.`) + : decideGuestShareCommand({ + thread, + command, + now, + }); + if (Result.isFailure(decided)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: decided.failure, + }); + } + if (decided.success === thread) return; + yield* emit( + events, + command, + )({ + type: "thread.guest-share-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: decided.success, + }); + yield* releaseGuestAttachments(command, thread.guestShare, decided.success.guestShare, effects); + }); + + // Emitted before the message so the send and the status change commit together, and so + // `dispatchMessage`, which reads pending events, keeps the updated share. + const emitGuestMessageSent = Effect.fn("orchestrationV2.dispatch.guestMessageSent")(function* ( + command: Extract, + guestMessageId: GuestMessageId, + thread: OrchestrationV2AppThread, + events: Ref.Ref>, + ) { + const now = yield* DateTime.now; + const decided = decideGuestMessageSend({ thread, guestMessageId, now }); + if (Result.isFailure(decided)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: decided.failure, + }); + } + yield* emit( + events, + command, + )({ + type: "thread.guest-share-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: decided.success.thread, + }); + return decided.success; + }); + const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( command: Extract< OrchestrationV2ServerCommand, @@ -2733,8 +2854,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is settled or archived and cannot watch pull requests.`, }); } - // A subagent or delegated task reports to its parent thread, which owns the pull request. - // Its own watch would wake it on every change of a PR it was not asked to babysit. + // Child tasks report to their parent, which owns PR monitoring; child watches would cause unrelated wakes. if ( command.type === "thread.pull-request.watch" && command.watching && @@ -2746,8 +2866,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is a subagent; its parent thread watches pull requests.`, }); } - // Only the agent's watch_pull_request links as "agent". A call that raced a Stop may land - // after its run ended, so the latest run that executed decides. A user can still watch. + // Agent watches use the latest executed run to catch calls racing Stop; users can still watch. if ( command.type === "thread.pull-request.watch" && command.watching && @@ -2834,9 +2953,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived.`, }); } - // Only pinned threads have a slot in the arranged order. Rejecting - // (rather than silently pinning) keeps a raced reorder-after-unpin from - // resurrecting a pin the user just cleared. + // Reject reorder after unpin so a raced request cannot restore a cleared pin. if (command.type === "thread.pin.reorder" && thread.pinnedAt == null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -3101,6 +3218,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, archivedAt: now, titleRegeneration: null, + guestShare: null, pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), updatedAt: now, }; @@ -3138,9 +3256,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } case "thread.snooze": { - // Always restamp, even for the same wake time: a re-snooze after a - // failure or completion woke the thread means "I saw it, not now". - // Retries are deduplicated by commandId before reaching here. + // Re-snoozing acknowledges the latest wake even at the same time; commandId deduplicates retries. return { ...thread, snoozedUntil, @@ -3238,8 +3354,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now) ? { snoozedUntil: DateTime.makeUnsafe(limitRecovery.resetAt), - // Recovery changes acknowledge the same stopped run; keep its - // metadata timestamp from appearing as a fresh failure wake. + // Recovery updates acknowledge the same stopped run; their timestamps must not trigger fresh failure wakes. snoozedAt: now, } : command.limitRecovery !== undefined && @@ -3303,14 +3418,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? { ...existing, source: command.source, - url: command.url, + url: canonicalizeGitCafePullRequestUrl(command.url), ...(command.repositoryWebUrl !== undefined && { repositoryWebUrl: command.repositoryWebUrl, }), } : { ...key, - url: command.url, + url: canonicalizeGitCafePullRequestUrl(command.url), ...(command.repositoryWebUrl !== undefined && { repositoryWebUrl: command.repositoryWebUrl, }), @@ -3331,7 +3446,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? [ { ...branchKey, - url: branch.url, + url: canonicalizeGitCafePullRequestUrl(branch.url), source: "manual" as const, linkedAt: DateTime.formatIso(now), snapshot: null, @@ -3406,7 +3521,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...linked.filter((link) => !threadPullRequestKeysEqual(link, key)), { ...key, - url: command.link.url, + url: canonicalizeGitCafePullRequestUrl(command.link.url), ...((command.link.repositoryWebUrl ?? dismissed?.repositoryWebUrl) !== undefined && { repositoryWebUrl: @@ -3602,6 +3717,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: updatedThread, }); + // Archiving ends the guest share, so its waiting files go too. + yield* releaseGuestAttachments(command, thread.guestShare, updatedThread.guestShare, effects); if (command.type === "thread.metadata.update" && command.regenerateTitle === true) { yield* Ref.update(effects, (existing) => [ @@ -3709,13 +3826,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } - // Settle joins archive here: both mean "done with this - // thread", so a live provider session must not keep running background - // work (PR monitors, dev servers, subagent fleets) after any of them - // lands. The settle guard above already rejects active or blocked runs, - // so for settle this only ever stops an idle session; commands are - // decided serially against the projection, so a turn start that - // re-engages the thread cannot race this detach. + // Archive and settle stop idle background work; serialized commands prevent starts racing detach. + // Settlement rejects active or blocked runs. const detachSessionIds = new Set( command.type === "thread.archive" || command.type === "thread.settle" ? (providerContext?.providerSessions ?? []).map((session) => session.id) @@ -3783,9 +3895,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : command.type === "thread.runtime-mode.set" ? "Runtime mode changed." : "Provider or model selection changed.", - // Terminal detaches revoke the thread's MCP credentials; other - // detach reasons keep them so a re-attaching provider process - // stays authorized. + // Terminal detaches revoke MCP credentials; reattachments need other detach reasons to preserve them. ...(command.type === "thread.archive" ? { revokeMcpCredential: true } : {}), }, } satisfies PendingOrchestrationEffectV2; @@ -3917,8 +4027,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // The source is not under this command's lock, so check the modes this - // command copies, not an earlier read the source's user could outrun. + // The source is unlocked; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); @@ -4009,8 +4118,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // The source is not under this command's lock, so check the modes this - // command copies, not an earlier read the source's user could outrun. + // The source is unlocked; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const targetProjection = yield* projectionStore .getThreadRecords(command.targetThreadId, ["contextTransfers"]) @@ -4155,6 +4263,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly creationSource: OrchestrationV2ConversationMessage["creationSource"]; readonly scheduledTaskId?: OrchestrationV2ConversationMessage["scheduledTaskId"]; readonly senderThreadId?: OrchestrationV2ConversationMessage["senderThreadId"]; + readonly idleCompaction?: OrchestrationV2ConversationMessage["idleCompaction"]; readonly delegatedCompletion?: OrchestrationV2ConversationMessage["delegatedCompletion"]; readonly forceRestart: boolean; }) { @@ -4312,6 +4421,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: input.scheduledTaskId }), ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), id: input.messageId, threadId: input.command.threadId, runId: messageInput.runId, @@ -4331,6 +4441,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: input.scheduledTaskId }), ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: input.messageId }), threadId: input.command.threadId, runId: messageInput.runId, @@ -5266,6 +5377,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), forceRestart: dispatchMode.type === "restart_active", }); return; @@ -5294,14 +5406,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode.type === "start_immediately" || dispatchMode.type === "queue_after_active"; const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun; - // A message the user sends from a client means they are back, so the - // queue a stop or a restart held runs again, in its order, rather than - // waiting for a Resume. - // Continuations and scheduled tasks the server sends for them are not - // them coming back. Starting the queue still re-holds it for a failed - // provider or a usage limit; a queue held after worktree setup failed - // keeps its hold until a worktree exists, even once later messages have - // run, since its messages would start in the project checkout. + // 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( @@ -5501,6 +5608,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -5841,6 +5949,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -5862,6 +5971,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -6528,6 +6638,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -6549,6 +6660,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -7236,9 +7348,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) .at(-1); if (message?.runId == null) return; - // Only a message that starts a run or a new attempt can own a task. A - // steer joins the running attempt, which finalizes its run from the - // snapshot taken when it started, so a task bound to it would never settle. + // Only new attempts can own tasks; steers join an existing snapshot that cannot settle newly bound tasks. const startsAttempt = pending.some( (event) => (event.type === "run.created" && event.payload.id === message.runId) || @@ -7380,11 +7490,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - // The wait path uses this when its blocking window ends without a terminal - // (timeout), so a child that later terminalizes mid-parent-turn still wakes - // the parent. Runs under the parent thread's dispatch lock, which is also - // what finalize takes for its parent-side writes, so the two never interleave - // on this row. + // After blocking waits time out, enable later completions to wake the parent. + // Hold the parent dispatch lock shared with finalization to serialize full-row writes. const dispatchDelegatedTaskWakePolicy = Effect.fn( "orchestrationV2.dispatch.delegatedTaskWakePolicy", )(function* ( @@ -7426,8 +7533,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completionWake: command.completionWake, updatedAt: now, }; - // When that run is not live, finalize already offered and a second offer would wake the - // parent twice. + // Finalization already offered for ended parent runs; another offer would duplicate the wake. const parentRun = task.runId === null ? undefined @@ -8001,6 +8107,65 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + /** + * Respond resolves the request before delivery, and a failed delivery cannot + * tell whether the provider is still waiting, so the answer stays recorded and + * the timeline shows the failure instead of a run spinning with no question. + */ + const dispatchRuntimeRequestDeliveryFail = Effect.fn( + "Orchestrator.dispatchRuntimeRequestDeliveryFail", + )(function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "runtime-request.delivery.fail" } + >, + events: Ref.Ref>, + ) { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe(mapDispatchError(command)); + if (thread.deletedAt !== null) return; + const node = (yield* projectionStore + .getRuntimeResponseContext(command.threadId, command.requestId) + .pipe(mapDispatchError(command))).node; + if (node === undefined) return; + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + ...(node.runId === null ? {} : { runId: node.runId }), + nodeId: node.id, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`turn-item:runtime-request-delivery-failure:${command.requestId}`), + threadId: command.threadId, + runId: node.runId, + nodeId: node.id, + providerThreadId: node.providerThreadId, + providerTurnId: node.providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal({ thread }), + status: "failed", + title: "Answer delivery not confirmed", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "error", + failure: { + class: "transport_error", + message: RUNTIME_REQUEST_DELIVERY_FAILED_MESSAGE, + code: null, + retryable: null, + }, + }, + }); + }); + const dispatchThreadUserInputDismiss = Effect.fn("Orchestrator.dispatchThreadUserInputDismiss")( function* ( command: Extract, @@ -8175,6 +8340,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.senderThreadId === undefined ? {} : { senderThreadId: queuedMessage.senderThreadId }), + ...(queuedMessage.idleCompaction === undefined + ? {} + : { idleCompaction: queuedMessage.idleCompaction }), forceRestart: false, }); }); @@ -8354,6 +8522,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } + const guest = queuedMessage?.context?.guest; + if (guest !== undefined) { + const thread = guestShareAfterCancelledSend(projection.thread, guest, now); + if (thread !== projection.thread) { + yield* emitEvent({ + type: "thread.guest-share-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: thread, + }); + } + } }); const dispatchQueuedRunEdit = Effect.fn("Orchestrator.dispatchQueuedRunEdit")(function* ( @@ -8401,6 +8582,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "Automatic completion deliveries cannot be edited.", }); } + // The agent text and the shown text of a guest message must stay the guest's words. + if (queuedMessage.context?.guest !== undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "A guest's message cannot be edited. Cancel it instead.", + }); + } const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, ); @@ -8416,13 +8605,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, - payload: { + payload: withoutGuestAttribution({ ...queuedMessage, text: command.text, ...editedAttachments, ...(command.context ? { context: command.context } : {}), updatedAt: now, - }, + }), }); if (queuedTurnItem !== undefined && queuedTurnItem.type === "user_message") { yield* emitEvent({ @@ -8432,13 +8621,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, - payload: { + payload: withoutGuestAttribution({ ...queuedTurnItem, text: command.text, ...editedAttachments, ...(command.context ? { context: command.context } : {}), updatedAt: now, - }, + }), }); } }); @@ -9209,11 +9398,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); - /** - * Its agent can call tools - * until the provider stops it, and a slow call can land after that, but nothing it starts - * may outlive the Stop. A restart continuation of that run must not start either. - */ + /** Late tool calls and restart continuations must not outlive Stop. */ const stopReachedRun = ( command: OrchestrationV2ServerCommand, threadId: ThreadId, @@ -9581,12 +9766,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); const run = projection.runs.find((candidate) => candidate.id === command.runId); const subagent = projection.subagents.find((candidate) => candidate.id === command.subagentId); - // A user Stop cancels an app-owned task here when nothing else can settle it: no child run was - // ever bound to it and it is not the child's original task (#19002). This holds whatever else - // the child is doing. A task with a bound run, live or ended, keeps its settlement path, since - // its result may still be on the way, and the child stop the client sends next interrupts the - // run. A task that already ended is written again, so a client holding a stale copy, such as - // one cached before a repair, shows it ended. + // User Stop cancels app-owned tasks with no bound child run unless they are the child's + // original task, regardless of other child work. Bound runs keep their settlement path + // because results may still arrive; the client's next child stop interrupts the run. + // Rewrite ended tasks so clients with stale copies see them end. if ( subagent?.origin === "app_owned" && subagent.childThreadId !== null && @@ -9809,11 +9992,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const runOrdinalOf = (item: (typeof projection.turnItems)[number]) => item.runId === null ? -1 : (runOrdinals.get(item.runId) ?? -1); - // Each provider thread is interrupted at its latest pending work: that - // turn's run bounds the settle follow-up, which must cover all of the - // thread's work. The target comes from the item's provider turn, since - // a native subagent item names its own provider thread but its - // parent's turn; interrupting the parent's turn reaches the subagent. + // Interrupt each provider thread at its latest pending turn so settlement covers all its work. + // Use the item's provider turn: native subagent items name the child thread but the parent turn, + // and interrupting the parent turn reaches the subagent. const latestTurnByProviderThread = new Map< OrchestrationV2ProviderThread["id"], { readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number } @@ -10583,9 +10764,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio offer: false, }; } - // settled_only defers to a blocking delegate_task wait, which lives only as - // long as the turn that spawned the task. Once that turn is over (a restart - // continues it as a new run), nothing else delivers the result. + // settled_only relies on a blocking wait that ends with the spawning turn; later results need another delivery path. if ( (input.task.completionWake ?? "settled_only") === "settled_only" && hasLiveRun({ runs: [input.parentRun] }) @@ -10659,10 +10838,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } if (deliveryRun !== undefined) { - // The terminal-run listener owns reconciliation of a completed wake. - // A sibling that wins the parent lock first remains pending for the - // successor that listener reserves, rather than creating a competing - // delivery. + // The terminal-run listener reserves successor wakes; keep racing siblings pending to avoid competing deliveries. return { task: { ...input.updatedTask, @@ -10738,12 +10914,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); /** - * Every mutation here targets the PARENT thread, so callers must hold the - * parent thread's dispatch lock rather than the child's: the - * delegated_task.wake-policy handler rewrites the same subagent row under - * that lock with a full-row payload, and unserialized writers clobber each - * other (stale policy on the terminal row, or a terminal row regressed to - * running). + * Callers must hold the parent thread's dispatch lock: wake-policy updates the same + * subagent row with full-row payloads, so unserialized writers clobber policy or terminal status. */ const finalizeAppOwnedSubagentTask = Effect.fn("Orchestrator.finalizeAppOwnedSubagentTask")( function* ( @@ -11414,10 +11586,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* dispatchThreadVisit(command, events); break; case "thread.auto-settle": { - // Automatic settlement (#8600): the sweep evaluated a shell snapshot, - // so re-check against the live thread before settling. Any change - // after the snapshot — or any explicit override, including the - // un-settle button's "active" — wins over the sweep. + // Recheck live state; changes and explicit overrides take precedence over the sweep snapshot. const thread = yield* projectionStore .getThread(command.threadId) .pipe( @@ -11502,13 +11671,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request-watch.sync": yield* dispatchPullRequestWatchSync(command, events, effects); break; + case "thread.guest-share.open": + case "thread.guest-share.redeem": + case "thread.guest-share.close": + case "thread.guest-message.submit": + case "thread.guest-message.dismiss": + yield* dispatchGuestShare(command, events, effects); + break; case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; case "message.dispatch": { - // The provider owns a native subagent's conversation, so a sent - // message has nowhere to go. Answers to a subagent's questions never - // target it either: adapters ask them on the top-level parent thread. + // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent. const thread = yield* projectionStore .getThread(command.threadId) .pipe( @@ -11522,8 +11696,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: command.threadId, }); } - yield* dispatchMessage(command, events, effects); - yield* recordSubagentFollowup(command, events).pipe(mapDispatchError(command)); + const guestSend = + command.guestMessageId === undefined + ? undefined + : yield* emitGuestMessageSent(command, command.guestMessageId, thread, events); + const dispatched = + guestSend === undefined + ? withoutGuestAttribution(command) + : { + ...command, + text: guestSend.text, + context: { version: 1 as const, records: [], guest: guestSend.guest }, + }; + yield* dispatchMessage(dispatched, events, effects); + yield* recordSubagentFollowup(dispatched, events).pipe(mapDispatchError(command)); break; } case "notification.delivery.accept": @@ -11679,6 +11865,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "checkpoint.rollback.fail": yield* dispatchCheckpointRollbackFail(command, events); break; + case "runtime-request.delivery.fail": + yield* dispatchRuntimeRequestDeliveryFail(command, events); + break; case "thread.background-work.settle": yield* dispatchBackgroundWorkSettle(command, events, effects); break; @@ -11776,15 +11965,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { sequence: receipt.resultSequence, storedEvents, + replayed: true, } satisfies OrchestratorV2DispatchResult; } - // A limited sender checked these modes before dispatching; the thread's - // user may have raised them since, and only here can they not change. + // Recheck under the lock; the user may have raised modes since sender preflight. const limit = yield* DispatchModeLimit; if (limit !== undefined) { - // A fork or merge-back source is not under this lock: its dispatch - // checks the copy it reads instead. + // Fork and merge-back sources are unlocked; validate the modes copied by their dispatch. const threadId = commandThreadId(command); const shell = yield* projectionStore .getThreadShell(threadId) @@ -11797,7 +11985,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio planned.events.length > 0 || command.type === "thread.background-work.settle" || command.type === "thread.stop" || - command.type === "subagent.stop" + command.type === "subagent.stop" || + // A second Stop sharing, or one after the share already ended, is not an error. + command.type === "thread.guest-share.close" ? Effect.succeed(planned) : Effect.fail( new OrchestratorDispatchError({ @@ -11848,9 +12038,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (plan.events.length === 0 && plan.effects.length === 0) { // A settle that ended nothing still records its receipt: a replayed Stop // effect then finds it instead of settling work that appeared since. - const resultSequence = yield* Effect.gen(function* () { + const { resultSequence, inserted } = yield* Effect.gen(function* () { const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) }); - yield* commandReceipts.insertIfAbsent({ + const inserted = yield* commandReceipts.insertIfAbsent({ commandId: command.commandId, threadId: commandThreadId(command), commandType: command.type, @@ -11859,7 +12049,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio status: "accepted", error: null, }); - return sequence; + return { resultSequence: sequence, inserted }; }).pipe( Effect.mapError( (cause) => @@ -11870,7 +12060,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - return { sequence: resultSequence, storedEvents: [] } satisfies OrchestratorV2DispatchResult; + return { + sequence: resultSequence, + storedEvents: [], + ...(inserted ? {} : { replayed: true as const }), + } satisfies OrchestratorV2DispatchResult; } const acceptedAt = plan.events.at(-1)?.occurredAt ?? (yield* DateTime.now); const committed = yield* eventSink @@ -11928,6 +12122,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { sequence: committed.receipt.resultSequence, storedEvents: committed.storedEvents, + ...(committed.committed ? {} : { replayed: true as const }), } satisfies OrchestratorV2DispatchResult; }); @@ -11965,12 +12160,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (stored.event.type !== "run.updated") return; const threadId = stored.event.threadId; const run = stored.event.payload; - // finalize writes the parent thread and startNextQueuedRun writes this - // thread, so each takes its own thread's lock, sequentially and never - // nested: dispatchDelegatedTaskRequest already writes child events - // while holding the parent lock, so nesting the parent lock inside the - // child lock here would invert that order, and the keyed executor's - // semaphores are neither reentrant nor deadlock-aware. + // Take parent and child locks sequentially, never nested: delegation holds the parent + // 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) { yield* threadDispatch.withLock( threadId, @@ -12010,12 +12202,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); - // Historical terminal events are already represented by the projections - // below. Replaying the full event table on every server start delays live - // queue promotion in proportion to the lifetime size of the database. + // Projections already contain historical terminals; replaying them delays startup queue promotion with database size. const terminalEventsAfterSequence = yield* eventSink.latestSequence().pipe(Effect.orDie); - // Queue promotion can wait on a provider or a thread lock. Subscribe to run - // updates before buffering so that wait never retains unrelated tool bodies. + // Filter run updates before buffering so blocked queue promotion does not retain unrelated tool bodies. yield* eventSink .stream({ afterSequence: terminalEventsAfterSequence, eventType: "run.updated" }) .pipe( @@ -12038,7 +12227,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A child whose run ends while its background tasks or its own subagents still run has no // result yet. When that work settles there is no run update to finalize it, so these events - // do it (#17512). A background roster counts once it empties after it was seen non-empty. + // do it. A background roster counts once it empties after it was seen non-empty. const providerThreadsWithBackgroundWork = new Set(); const settlesChildWork = (stored: OrchestrationV2StoredEvent) => { const { event } = stored; @@ -12122,10 +12311,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId, Effect.gen(function* () { const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"]); - // Only the run that consumed a cohort's current delivery - // message can settle it. A long-lived parent keeps every - // past delivery run, and finalizing each of them would - // reload the thread once per historical delivery. + // Only the current delivery's run can settle its cohort; revisiting historical delivery runs repeatedly reloads the thread. const openDeliveryMessageIds = new Set( projection.runs.flatMap((run) => run.delegatedCompletion?.delivery == null @@ -12329,6 +12515,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projectionStore .getThreadShell(threadId) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + findGuestShareThreadId: (shareId) => + projectionStore.findGuestShareThreadId(shareId).pipe(Effect.mapError(shellProjectionError)), getThreadEventSequence: (threadId) => eventSink .latestSequence({ threadId }) @@ -12350,10 +12538,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ), - // Live tail only. eventSink.stream() with no cursor replays the whole - // store from genesis first; domain-event subscribers (the awareness relay) - // react to new activity, and startup replay made them grind through the - // entire event history doing per-event work after every boot. + // Start at the latest sequence; awareness needs no historical replay. streamDomainEvents: Stream.unwrap( eventSink .latestSequence() @@ -12484,6 +12669,13 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + findGuestShareThreadId: () => + Effect.fail( + new OrchestratorProjectionError({ + threadId: ThreadId.make("thread:shell"), + cause: "Orchestration V2 live runtime is not configured.", + }), + ), getThreadEventSequence: (threadId) => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index f3814f42ea7e..674a04783e34 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -104,10 +104,7 @@ export interface ProjectCommandState { const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); -/** - * The caller reads `state` under the project's lock and commits the planned - * event. - */ +/** Read state and commit the planned event under the project lock. */ export function planProjectCommand(input: { readonly command: ProjectCommand; readonly state: ProjectCommandState; @@ -180,8 +177,7 @@ export function planProjectCommand(input: { return invariant("Project monograms must contain at most two characters."); } if (command.scripts !== undefined) { - // Persisted IDs predate shortcut validation. Let users edit or remove them - // without allowing another invalid ID to enter the project. + // Allow editing or removing persisted invalid script ids, while rejecting new invalid ids. const existingIds = new Set(project.scripts.map((script) => script.id)); for (const script of command.scripts) { if (!existingIds.has(script.id) && !isScriptRunCommand(`script.${script.id}.run`)) { diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index aa7b27de6048..335522c7542a 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -96,7 +96,7 @@ export class ProjectStoreV2 extends Context.Service< } >()("t3/orchestration-v2/ProjectStore/ProjectStoreV2") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const encodeRow = Schema.encodeEffect(ProjectDbRow); diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 6c5835d8f40e..595bd0d06f93 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -196,9 +196,7 @@ export const layer: Layer.Layer< Effect.mapError((cause) => new ProjectionMaintenanceError({ operation, cause })), ); - // thread.created stays out — verify derives the expected thread set from - // it, and it anchors replay ordering. thread.deleted stays out too: clients - // and waitForThread switch on the event type, not just the payload. + // Keep thread.created as the verification and replay anchor; clients recognize deletion by thread.deleted event type. const SUPERSEDABLE_THREAD_EVENT_TYPES = [ "thread.archived", "thread.unarchived", @@ -217,11 +215,9 @@ export const layer: Layer.Layer< "thread.interaction-mode-updated", "thread.model-selection-updated", "thread.provider-switched", + "thread.guest-share-updated", ]; - // Read-state events carry the whole thread too, but client reducers apply - // them without bumping updatedAt. They supersede only each other: a newer - // visit must not delete the rename that carries the activity timestamp, - // while any newer activity-bearing event supersedes both groups. + // Read-state events preserve updatedAt and supersede only each other. Activity events supersede both groups. const READ_STATE_THREAD_EVENT_TYPES = ["thread.visited", "thread.marked-unread"]; const supersedableThreadEventTypes = new Set(SUPERSEDABLE_THREAD_EVENT_TYPES); @@ -230,16 +226,8 @@ export const layer: Layer.Layer< // Small pages bound work on the reader worker and synchronous fallback. const COMPACTION_PAGE_SIZE = 50; - /** - * Scan newest first to retain the newest state for each entity. Keyed - * entities (messages, nodes, PR links) also keep their oldest event: client - * reducers append an entity where they first see it, so a replay from any - * cursor must either carry that first event or find the entity already - * present. Only the events in between are deleted. - * Page every event, including non-candidates: filtering before LIMIT could - * still scan the entire history when superseded events are sparse. - * turn-item.updated stays intact because replay assigns positions on first write. - */ + /** Retain newest and oldest keyed events to preserve client insertion order; page all events to bound sparse scans. + * Keep turn-item.updated because replay assigns positions on first write. */ const compactEventStore = Effect.gen(function* () { const bounds = yield* sql<{ readonly event_sequence: number; @@ -253,8 +241,7 @@ export const layer: Layer.Layer< let throughReceiptRowId = bounds[0]?.receipt_row_id ?? 0; const retainedThreadIds = new Set(); const retainedReadStateThreadIds = new Set(); - // Per key: the lowest sequence seen so far after the newest. It is - // deleted once an even older event turns up and kept otherwise. + // Retain each key's oldest candidate, deleting it only when an older event appears. const oldestCandidateByEntityKey = new Map(); let deletedEventCount = 0; let deletedReceiptCount = 0; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index fcadd8036b92..251716acb446 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -40,8 +40,10 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import { buildBoundedThreadProjection, decodeThreadHistoryCursor, + encodeThreadHistoryCursor, selectHistoryPageFromCursor, THREAD_HISTORY_PAGE_POLICY, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; const layerTest = Layer.mergeAll( @@ -409,7 +411,6 @@ it.effect.each([ }); const providerThreadId = ProviderThreadId.make("provider-thread:duplicate-owner"); const nativeTaskId = "native-task:duplicate-provider-thread-owner"; - // The latest upsert moves the row off the later-created thread. for (const [appThreadId, owner] of [ [otherThreadId, otherInstanceId], [threadId, providerInstanceId], @@ -497,7 +498,6 @@ it.effect.each([ modelSelection: { ...modelSelection, instanceId: otherInstanceId }, }, }); - // The other thread's item reads its owner from a provider thread stored on another thread. assert.deepEqual((yield* store.getThreadProjection(otherThreadId)).providerThreads, []); const assertOwners = Effect.fnUntraced(function* ( targetThreadId: ThreadId, @@ -1105,6 +1105,92 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio return { projectId, activeThreadId, forkThreadId, otherThreadId }; }); +it.effect.each([ + ["memory", ProjectionStore.layerMemory], + ["SQL", layerTest], +] as const)("%s shells read a subagent family and a worktree's threads", ([, layer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = ProjectId.make("project:family"); + const worktreePath = "/repo/.t3/worktrees/family"; + const id = (name: string) => ThreadId.make(`thread:family:${name}`); + const payloadOf = (input: { + readonly name: string; + readonly parent?: string; + readonly root?: string; + readonly worktreePath?: string; + readonly archived?: boolean; + }) => + ({ + createdBy: "user", + creationSource: input.parent === undefined ? "web" : "mcp", + id: id(input.name), + projectId, + title: input.name, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: input.worktreePath ?? null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.parent === undefined ? null : id(input.parent), + relationshipToParent: input.parent === undefined ? null : "subagent", + rootThreadId: id(input.root ?? input.name), + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: input.archived === true ? now : null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }) as const; + const createThread = (input: Parameters[0]) => + store.apply({ + id: EventId.make(`event:family:${input.name}`), + type: "thread.created", + threadId: id(input.name), + occurredAt: now, + payload: payloadOf(input), + }); + yield* createThread({ name: "root", worktreePath }); + yield* createThread({ name: "child", parent: "root", root: "root", worktreePath }); + yield* createThread({ name: "grandchild", parent: "child", root: "root" }); + yield* createThread({ name: "archived-child", parent: "root", root: "root", archived: true }); + yield* createThread({ name: "other", worktreePath: "/repo" }); + yield* createThread({ name: "other-child", parent: "other", root: "other", worktreePath }); + + const family = (threadId: ThreadId) => + store + .getShellSnapshot({ location: "active", subagentFamilyOf: threadId }) + .pipe(Effect.map((snapshot) => snapshot.threads.map((thread) => thread.id))); + // Any member, or the root itself, names the same active subagent tree. + assert.sameMembers([...(yield* family(id("root")))], [id("child"), id("grandchild")]); + assert.sameMembers([...(yield* family(id("grandchild")))], [id("child"), id("grandchild")]); + assert.deepEqual([...(yield* family(id("missing")))], []); + + // Includes another root's idle child on the same path, which a shell may leave out. + const onWorktree = yield* store.getShellSnapshot({ location: "active", worktreePath }); + assert.sameMembers( + onWorktree.threads.map((thread) => thread.id), + [id("root"), id("child"), id("other-child")], + ); + // A fork keeps its deleted source's root, so the deleted root still names the live tree. + yield* store.apply({ + id: EventId.make("event:family:root-deleted"), + type: "thread.deleted", + threadId: id("root"), + occurredAt: now, + payload: { ...payloadOf({ name: "root", worktreePath }), deletedAt: now }, + }); + assert.sameMembers([...(yield* family(id("root")))], [id("child"), id("grandchild")]); + }).pipe(Effect.provide(layer)), +); + it.effect("lists only active subagent IDs without loading shell data", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -1529,6 +1615,126 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { [baselineId], ); } + const humanWindow = () => + projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: 77, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, + }); + assert.lengthOf( + (yield* humanWindow()).projection.turnItems, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, + ); + // With the newest user turn 1,000 rows back, the window pages from inside it. + const setNewestTurnType = (type: "command_execution" | "user_message") => sql` + UPDATE orchestration_v2_projection_turn_items + SET type = ${type}, payload_json = json_set(payload_json, '$.type', ${type}) + WHERE thread_id = ${threadId} AND ordinal = 1000 + `; + yield* setNewestTurnType("command_execution"); + assert.lengthOf((yield* humanWindow()).projection.turnItems, 77); + yield* setNewestTurnType("user_message"); + + // Fewer than 75 rows still need a byte pass after materializing wire payloads. + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'assistant_message', payload_json = json_set(payload_json, + '$.type', 'assistant_message', '$.text', ${"x".repeat(40_000)}, + '$.messageId', turn_item_id, '$.streaming', json('false')) + WHERE thread_id = ${threadId} AND ordinal BETWEEN 2 AND 70 + `; + const byteCursor = encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:71"), + position: 70, + }); + const bytePage = yield* projectionStore.getThreadHistoryPage(threadId, byteCursor); + assert.isAbove(bytePage.items.length, 0); + assert.isBelow(bytePage.items.length, 70); + assert.isAtMost( + Buffer.byteLength(JSON.stringify(bytePage.items), "utf8"), + THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + ); + const byteIds = bytePage.items.map((row) => String(row.sourceItemId)); + let byteNext = bytePage.nextCursor; + for (let pages = 0; byteNext !== null; pages += 1) { + assert.isBelow(pages, 70); + const page = yield* projectionStore.getThreadHistoryPage(threadId, byteNext); + assert.isAbove(page.items.length, 0); + byteIds.unshift(...page.items.map((row) => String(row.sourceItemId))); + byteNext = page.nextCursor; + } + assert.deepEqual( + byteIds, + Array.from({ length: 70 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`), + ); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal BETWEEN 2 AND 70 + `; + + // A targeted page that needs more than 20 human turns must still reach its target + // after the byte pass re-bounds it. + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'user_message', payload_json = json_set(payload_json, + '$.type', 'user_message', '$.createdBy', 'user', '$.creationSource', 'web', + '$.inputIntent', 'turn_start', '$.messageId', turn_item_id, '$.text', 'Turn', + '$.attachments', json('[]')) + WHERE thread_id = ${threadId} AND ordinal BETWEEN 600 AND 624 + `; + const targetedPage = yield* projectionStore.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:625"), + position: 624, + }), + "turn-item:bounded-sql-history:600", + ); + assert.strictEqual( + String(targetedPage.items[0]?.sourceItemId), + "turn-item:bounded-sql-history:600", + ); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal BETWEEN 600 AND 624 + `; + + // A turn split by the row budget keeps an interrupt result's request on the same page. + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:500', + type = CASE ordinal WHEN 500 THEN 'run_interrupt_request' ELSE 'run_interrupt_result' END, + payload_json = json_set(payload_json, + '$.runId', 'run:bounded-sql-history:500', '$.message', 'Stop', + '$.type', CASE ordinal WHEN 500 THEN 'run_interrupt_request' ELSE 'run_interrupt_result' END) + WHERE thread_id = ${threadId} AND ordinal IN (500, 580) + `; + const interruptPage = yield* projectionStore.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:590"), + position: 589, + }), + ); + const interruptIds = interruptPage.items.map((row) => String(row.sourceItemId)); + assert.include(interruptIds, "turn-item:bounded-sql-history:580"); + assert.include(interruptIds, "turn-item:bounded-sql-history:500"); + assert.notInclude(interruptIds, "turn-item:bounded-sql-history:501"); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:' || ordinal, type = 'command_execution', + payload_json = json_set(payload_json, + '$.runId', 'run:bounded-sql-history:' || ordinal, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal IN (500, 580) + `; + const retainedRequestPlan = yield* sql<{ readonly detail: string }>` EXPLAIN QUERY PLAN WITH selected AS ( @@ -1571,9 +1777,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2; const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }); const initialPage = buildBoundedThreadProjection({ - policy: { ...THREAD_HISTORY_PAGE_POLICY, maxUserTurns: undefined }, projection: initialSnapshot.projection, snapshotSequence: initialSnapshot.snapshotSequence, }); @@ -1586,10 +1792,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const anchorItemId = TurnItemId.make(decodeThreadHistoryCursor(cursor).si); const snapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, + userTurnLimit: 20, anchorItemId, }); + assert.isAtMost( + snapshot.projection.visibleTurnItems.length, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, + ); const page = selectHistoryPageFromCursor({ - policy: { ...THREAD_HISTORY_PAGE_POLICY, maxUserTurns: undefined }, items: snapshot.projection.visibleTurnItems, cursor, snapshotSequence: snapshot.snapshotSequence, @@ -1599,7 +1809,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { cursor = page.nextCursor; } - assert.isAtLeast(pageCount, 4); + assert.isAtLeast(pageCount, 3); assert.lengthOf(loadedIds, 1_000); assert.strictEqual(new Set(loadedIds).size, 1_000); assert.deepEqual( @@ -2879,6 +3089,150 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect.each([ + ["T3 Code's idle compaction", "agent", "server", undefined, true, "/compact"], + ["compacting on return", "user", "web", "onReturn", true, "/compact"], + ["a typed /compact", "user", "mobile", undefined, false, "/compact"], + ["a normalized idle command", "agent", "server", undefined, true, " \t\u00a0/COMPACT\u3000\n"], + ["an ordinary prompt with idle metadata", "user", "web", "onReturn", false, "Continue"], + ] as const)( + "marks the shell's latest run silent only for %s", + ([name, createdBy, creationSource, idleCompaction, silent, text]) => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const key = name.replaceAll(/\W+/g, "-"); + const threadId = ThreadId.make(`thread:projection-shell-silent:${key}`); + const runId = RunId.make(`run:projection-shell-silent:${key}`); + const messageId = MessageId.make(`message:projection-shell-silent:${key}`); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:thread`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:projection-shell-silent"), + title: "Idle compaction", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:message`), + type: "message.updated", + threadId, + occurredAt: now, + payload: { + createdBy, + creationSource, + ...(idleCompaction === undefined ? {} : { idleCompaction }), + id: messageId, + threadId, + runId, + nodeId: null, + role: "user", + text, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:run`), + type: "run.created", + threadId, + runId, + nodeId: NodeId.make(`node:projection-shell-silent:${key}`), + driver, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + + const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + const memoryShell = ProjectionStore.threadShellFromProjection( + yield* projectionStore.getThreadProjection(threadId), + ); + assert.equal(sqlShell?.latestRunSilent, silent); + assert.equal(memoryShell.latestRunSilent, silent); + + // Claude can finish the run successfully even when its /compact failed. + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:failure`), + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:projection-shell-silent:${key}:compaction`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "compaction", + driver, + status: "failed", + title: "Context compaction failed", + summary: "Not enough messages to compact.", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }); + assert.equal( + (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )?.latestRunSilent, + false, + ); + assert.equal( + ProjectionStore.threadShellFromProjection( + yield* projectionStore.getThreadProjection(threadId), + ).latestRunSilent, + false, + ); + }), + ); + it.effect("only exposes interruptible runs through the shell activeRunId", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -5684,6 +6038,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { taskId: "pull-request-watch:github.com/pingdotgg/t3code#7", description: "Watching pull request #7", kind: "monitor", + awaited: true, }, ]); assert.equal(yield* phase, "running"); @@ -5716,7 +6071,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { lastRunOrdinal: 1, handoffIds: [], forkedFrom: null, - pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" as const }], + pendingBackgroundTasks: [ + { taskId: "watch", kind: "monitor" as const, awaited: true as const }, + ], createdAt: completedAt, updatedAt: completedAt, }; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index f62d5e447aae..60b5de0edb5d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,4 +1,5 @@ -import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; +import { pendingGuestMessageCount } from "@t3tools/contracts"; +import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -14,7 +15,7 @@ import { threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import type { OrchestrationV2AppThread, OrchestrationV2Checkpoint, @@ -35,6 +36,7 @@ import type { OrchestrationV2ThreadShell, OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, + GuestShareId, ProjectId, ProviderSessionId, ProviderThreadId, @@ -42,6 +44,8 @@ import type { RunAttemptId, } from "@t3tools/contracts"; import { + IDLE_COMPACTION_TEXT, + idleCompactionOf, OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema, OrchestrationV2CheckpointJson as OrchestrationV2CheckpointJsonSchema, OrchestrationV2CheckpointScopeJson as OrchestrationV2CheckpointScopeJsonSchema, @@ -94,10 +98,12 @@ import { isConversationHistoryItem, threadHistoryTurnStarts, THREAD_HISTORY_MAX_RAW_TURNS, + missingInterruptRequests, + olderHistoryPagePolicy, selectHistoryPageFromCursor, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, - OLDER_THREAD_USER_TURN_LIMIT, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; export class ProjectionStoreApplyEventError extends Schema.TaggedError()( @@ -208,6 +214,7 @@ export type ProjectionSettlementCandidate = Pick< | "activityRunStatus" | "pendingRuntimeRequest" | "pendingBackgroundTasks" + | "pendingGuestMessageCount" > & Pick & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null; @@ -402,6 +409,12 @@ export interface ShellSnapshotOptions { readonly location?: "active" | "archive"; readonly projectId?: ProjectId; readonly unsettledOnly?: boolean; + /** + * Only the subagent threads sharing this thread's `lineage.rootThreadId`. The thread may be + * deleted: forks keep a deleted source's root, and their subagent trees still need it. + */ + readonly subagentFamilyOf?: ThreadId; + readonly worktreePath?: string; } export interface ProjectionStoreV2Shape { @@ -462,6 +475,10 @@ export interface ProjectionStoreV2Shape { readonly getThreadShell: ( threadId: ThreadId, ) => Effect.Effect; + /** The thread whose current guest share has this id. Scans thread rows; only redeem uses it. */ + readonly findGuestShareThreadId: ( + shareId: GuestShareId, + ) => Effect.Effect; readonly getThread: ( threadId: ThreadId, ) => Effect.Effect; @@ -718,7 +735,6 @@ function upsertProviderTurn( }); } -/** A stale run snapshot must not erase fields that other events recorded on the run. */ function preserveRunRecordedFields( current: OrchestrationV2Run | undefined, next: OrchestrationV2Run, @@ -816,6 +832,7 @@ export function applyToProjection( case "thread.interaction-mode-updated": case "thread.model-selection-updated": case "thread.provider-switched": + case "thread.guest-share-updated": return { ...base, thread: event.payload, @@ -957,12 +974,7 @@ export function applyToProjection( } } -/** - * Provider sessions are process-scoped: one session row can be bound to several app - * threads. Updating that row changes what every bound thread reads, even though the - * application event itself belongs to one thread stream. Provider thread owners are global - * too: the latest event wins across app threads. - */ +/** Sessions are shared across app threads, so session events update every binding. Provider-thread ownership is also global: latest wins. */ export interface ProjectionReplayState { readonly projections: Map; readonly providerSessionThreadIds: Map>; @@ -1084,6 +1096,7 @@ type ShellThreadRow = { readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; readonly latest_run_stop_requested: number; + readonly latest_run_silent: number; readonly active_run_id: string | null; readonly active_run_status: string | null; readonly activity_run_status: string | null; @@ -1141,8 +1154,7 @@ const encodeTimelineSources = Schema.encodeEffect( ); const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); -// An empty list filter selects no rows, but SQLite still walks every row of -// the thread to apply it. Callers pass `turnItemTypes: []` to skip items. +// Empty filters still scan SQLite rows; short-circuit them, including turnItemTypes: []. const matchesNothing = (...filters: ReadonlyArray | undefined>) => filters.some((values) => values?.length === 0); @@ -1634,6 +1646,16 @@ export function threadShellFromProjection( projection.turnItems.some( (item) => item.runId === latestRun.id && item.type === "run_interrupt_request", ), + latestRunSilent: + latestRun != null && + projection.messages.some( + (message) => + message.id === latestRun.userMessageId && idleCompactionOf(message) !== undefined, + ) && + !projection.turnItems.some( + (item) => + item.runId === latestRun.id && item.type === "compaction" && item.status === "failed", + ), activeRunId: activeRun?.id ?? null, blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }), activityRunStatus: activityRun?.status ?? null, @@ -1645,6 +1667,7 @@ export function threadShellFromProjection( providerSession?.lastError ?? null, ), pendingRuntimeRequest, + pendingGuestMessageCount: pendingGuestMessageCount(projection.thread.guestShare), // Thread detail owns message bodies. Keeping them out of shell rows makes // initial hydration and streaming updates independent of transcript size. latestVisibleMessage: null, @@ -1678,6 +1701,7 @@ export function threadShellFromProjection( autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, pinOrderKey: projection.thread.pinOrderKey ?? null, lastVisitedAt: projection.thread.lastVisitedAt, + opens: projection.thread.opens ?? null, titleRegeneration: projection.thread.titleRegeneration ?? null, limitRecovery: projection.thread.limitRecovery ?? null, deletedAt: projection.thread.deletedAt, @@ -1734,6 +1758,7 @@ type ShellThreadState = { readonly latestRunStartedAt: DateTime.Utc | null; readonly latestRunCompletedAt: DateTime.Utc | null; readonly latestRunStopRequested: boolean; + readonly latestRunSilent: boolean; readonly activeRunId: RunId | null; readonly blockedOnThreadWait: boolean; readonly activityRunStatus: ShellActivityRunStatus | null; @@ -1889,6 +1914,7 @@ function shellFromState(input: { latestRunStartedAt: input.state.latestRunStartedAt, latestRunCompletedAt: input.state.latestRunCompletedAt, latestRunStopRequested: input.state.latestRunStopRequested, + latestRunSilent: input.state.latestRunSilent, activeRunId: input.state.activeRunId, blockedOnThreadWait: input.state.blockedOnThreadWait, activityRunStatus: input.state.activityRunStatus, @@ -1899,6 +1925,7 @@ function shellFromState(input: { lastErrorCode: input.state.lastErrorCode, usageLimitResetAt: input.state.usageLimitResetAt, pendingRuntimeRequest: input.state.pendingRuntimeRequest, + pendingGuestMessageCount: pendingGuestMessageCount(input.state.thread.guestShare), latestVisibleMessage: null, latestUserMessageAt: input.state.latestUserMessageAt, latestUserAuthoredMessageAt: input.state.latestUserAuthoredMessageAt, @@ -1921,6 +1948,7 @@ function shellFromState(input: { autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, pinOrderKey: input.state.thread.pinOrderKey ?? null, lastVisitedAt: input.state.thread.lastVisitedAt, + opens: input.state.thread.opens ?? null, titleRegeneration: input.state.thread.titleRegeneration ?? null, limitRecovery: input.state.thread.limitRecovery ?? null, deletedAt: input.state.thread.deletedAt, @@ -1932,7 +1960,6 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // For run upserts: a snapshot without `path` keeps the value another event recorded there. const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql` CASE WHEN json_type(excluded.payload_json, ${path}) IS NULL @@ -1980,7 +2007,8 @@ export const layer: Layer.Layer = case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": case "thread.model-selection-updated": - case "thread.provider-switched": { + case "thread.provider-switched": + case "thread.guest-share-updated": { const payloadJson = yield* encodeThreadPayload(event.payload); const payload = parseEncodedPayload(payloadJson); yield* sql` @@ -2813,7 +2841,8 @@ export const layer: Layer.Layer = event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && event.type !== "thread.model-selection-updated" && - event.type !== "thread.provider-switched" + event.type !== "thread.provider-switched" && + event.type !== "thread.guest-share-updated" ) { const updatedAt = yield* encodeThreadUpdatedAt(event.occurredAt); yield* sql` @@ -2975,6 +3004,17 @@ export const layer: Layer.Layer = ELSE 0 END AS ordinal FROM user_anchors + ), latest_user_turn AS ( + -- Mirrors selectOlderTimelinePage: whole turns while the + -- newest user turn fits the turn row budget, otherwise the + -- window pages from inside that turn with the row limit. + SELECT COUNT(*) AS row_count + FROM ( + SELECT 1 + FROM eligible + WHERE ordinal >= (SELECT MAX(ordinal) FROM user_anchors) + LIMIT ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 1} + ) ), selected AS ( -- Choose and sort rows by ID, then fetch payloads in that -- order. A turn window can hold megabytes of tool output, and @@ -2985,7 +3025,9 @@ export const layer: Layer.Layer = ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 - WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1 + WHEN (SELECT row_count FROM latest_user_turn) + BETWEEN 1 AND ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems} + THEN ${THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT} ELSE ${window.rowLimit} END ), retained AS MATERIALIZED ( @@ -3064,8 +3106,7 @@ export const layer: Layer.Layer = ON item.rowid = retained.item_rowid ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; - // Reuse the decoded items for cohort IDs and the resulting projection. - // Parsing these rows separately duplicates every retained tool output. + // Decode items once for cohort ids and projection; repeated parsing duplicates retained tool output. const turnItems = yield* decodeRows(decodeTurnItemPayload, threadId)(boundedTurnItemRows); const cohortJson = (field: string) => JSON.stringify( @@ -3630,8 +3671,7 @@ export const layer: Layer.Layer = return withLocalVisibleTurnItems(projection); } - // A row-limited segment without turn anchors must finish paging locally - // before inherited user turns can influence the page boundary. + // Page unanchored local segments fully before inherited turns affect the boundary. if ( window?.userTurnLimit !== undefined && localWindow !== undefined && @@ -4969,8 +5009,7 @@ export const layer: Layer.Layer = if (threads.length === 0) { return yield* new ProjectionStoreThreadNotFoundError({ threadId }); } - // Checkpoint diffs need no transcript or fork ancestry. Select just the - // metadata columns so large run/checkpoint JSON payloads stay in SQLite. + // Select checkpoint metadata only; diffs need neither transcripts nor large run/checkpoint JSON. const [runs, checkpointScopes, checkpoints] = yield* Effect.all([ sql` SELECT run_id AS id, ordinal, status @@ -5250,11 +5289,21 @@ export const layer: Layer.Layer = WHERE thread_id = ${threadId} AND type = 'dynamic_tool' AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}) + `, + // A guest's waiting files are pending uploads that no message row holds yet. + sql<{ id: string }>` + SELECT DISTINCT json_extract(attachment.value, '$.id') AS id + FROM orchestration_v2_projection_threads AS thread, + json_each(thread.payload_json, '$.guestShare.messages') AS message, + json_each(message.value, '$.attachments') AS attachment + WHERE thread.thread_id = ${threadId} + AND json_extract(message.value, '$.status') = 'pending' `, ]).pipe( - Effect.map(([messages, renders]) => [ + Effect.map(([messages, renders, guestFiles]) => [ ...new Set([ ...messages.map((row) => row.id), + ...guestFiles.map((row) => row.id), ...threadHtmlRenderAttachmentIds( threadId, renders.map((row) => { @@ -5461,84 +5510,85 @@ export const layer: Layer.Layer = FROM orchestration_events WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId}`; const snapshotSequence = revisions[0]?.sequence ?? 0; - const page = yield* Effect.try({ - try: () => - selectHistoryPageFromCursor({ - items: index.visible.map((row, position) => ({ - ...row, - position, - canonicalPosition: position, - })), + const indexed = index.visible.map((row, position) => ({ + ...row, + position, + canonicalPosition: position, + })); + const selection = yield* Effect.try({ + try: () => { + const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); + const page = selectHistoryPageFromCursor({ + items: indexed, cursor, snapshotSequence, rowEncodedBytes: () => 0, - throughEntryId, - }), + policy, + }); + return { policy, page }; + }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); + const { policy, page } = selection; const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; const hasUserTurns = index.visible .slice(0, end) .some((row) => isThreadHistoryUserTurn(row.item)); - const materialized = - conversationOnly && hasUserTurns - ? page.items.filter((row) => isConversationHistoryItem(row.item)) - : page.items; - const sources = yield* encodeTimelineSources( - materialized - .filter((row) => !row.synthetic) - .map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })), - ); - const payloads = yield* sql<{ - thread_id: string; - turn_item_id: string; - payload_json: string; - }>` + const materialize = Effect.fn("ProjectionStore.materializeHistoryRows")(function* ( + rows: ReadonlyArray<(typeof indexed)[number]>, + ) { + if (rows.length === 0) return []; + const sources = yield* encodeTimelineSources( + rows + .filter((row) => !row.synthetic) + .map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })), + ); + const payloads = yield* sql<{ + thread_id: string; + turn_item_id: string; + payload_json: string; + }>` SELECT item.thread_id, item.turn_item_id, item.payload_json FROM orchestration_v2_projection_turn_items item JOIN json_each(${sources}) wanted ON item.thread_id = json_extract(wanted.value, '$.threadId') AND item.turn_item_id = json_extract(wanted.value, '$.id')`; - const byIdentity = new Map( - payloads.map((row) => [`${row.thread_id}:${row.turn_item_id}`, row.payload_json]), - ); - const items = yield* Effect.forEach(materialized, (row) => - Effect.gen(function* () { - const payload = byIdentity.get(`${row.sourceThreadId}:${row.sourceItemId}`); - const item = - row.synthetic ?? - (payload === undefined - ? yield* new ProjectionStoreReadError({ threadId }) - : yield* decodeTurnItemPayload(payload)); - return { - position: row.canonicalPosition, - visibility: row.visibility, - sourceThreadId: row.sourceThreadId, - sourceItemId: row.sourceItemId, - item: projectTurnItemForWire(item), - }; - }), - ); - if (hasUserTurns) - return { - ...page, - snapshotSequence, - items: items.map((row, position) => ({ ...row, position })), - }; - const bounded = selectRecentTimelineWindow({ - items, - snapshotSequence, - policy: { - ...THREAD_HISTORY_PAGE_POLICY, - maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT, - maxItems: - THREAD_HISTORY_PAGE_POLICY.maxItems * (throughEntryId === undefined ? 1 : 2), - }, + const byIdentity = new Map( + payloads.map((row) => [`${row.thread_id}:${row.turn_item_id}`, row.payload_json]), + ); + return yield* Effect.forEach(rows, (row) => + Effect.gen(function* () { + const payload = byIdentity.get(`${row.sourceThreadId}:${row.sourceItemId}`); + const item = + row.synthetic ?? + (payload === undefined + ? yield* new ProjectionStoreReadError({ threadId }) + : yield* decodeTurnItemPayload(payload)); + return { + position: row.canonicalPosition, + visibility: row.visibility, + sourceThreadId: row.sourceThreadId, + sourceItemId: row.sourceItemId, + item: projectTurnItemForWire(item), + }; + }), + ); }); + const items = yield* materialize( + conversationOnly && hasUserTurns + ? page.items.filter((row) => isConversationHistoryItem(row.item)) + : page.items, + ); + const bounded = selectRecentTimelineWindow({ items, snapshotSequence, policy }); + const requests = yield* materialize(missingInterruptRequests(indexed, bounded.items)); + const pageItems = [...requests, ...bounded.items].map((row, position) => ({ + ...row, + position, + })); return { snapshotSequence, items: conversationOnly - ? bounded.items.filter((row) => isConversationHistoryItem(row.item)) - : bounded.items, + ? pageItems.filter((row) => isConversationHistoryItem(row.item)) + : pageItems, nextCursor: bounded.hasMoreHistory ? bounded.nextCursor : page.nextCursor, hasMoreHistory: bounded.hasMoreHistory || page.hasMoreHistory, }; @@ -5664,7 +5714,13 @@ export const layer: Layer.Layer = const selectShellThreadRows = ( threadId?: ThreadId, - { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, + { + location, + projectId, + unsettledOnly = false, + subagentFamilyOf, + worktreePath, + }: ShellSnapshotOptions = {}, ) => sql` SELECT @@ -5685,6 +5741,29 @@ export const layer: Layer.Layer = WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id AND stop_request.type = 'run_interrupt_request' ) AS latest_run_stop_requested, + -- Mirrors idleCompactionOf, including JavaScript trim whitespace. + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_messages prompt + WHERE prompt.message_id = json_extract(presented.payload_json, '$.userMessageId') + AND prompt.role = 'user' + AND lower(trim(json_extract(prompt.payload_json, '$.text'), + char(9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8193, 8194, 8195, + 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279) + )) = ${IDLE_COMPACTION_TEXT} + AND coalesce(json_array_length(prompt.payload_json, '$.attachments'), 0) = 0 + AND ( + json_extract(prompt.payload_json, '$.idleCompaction') IS NOT NULL + OR ( + json_extract(prompt.payload_json, '$.createdBy') = 'agent' + AND json_extract(prompt.payload_json, '$.creationSource') = 'server' + ) + ) + ) AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items compaction + WHERE compaction.thread_id = t.thread_id AND compaction.run_id = presented.run_id + AND compaction.type = 'compaction' + AND compaction.status = 'failed' + ) AS latest_run_silent, active.run_id AS active_run_id, active.status AS active_run_status, ( @@ -5842,6 +5921,18 @@ export const layer: Layer.Layer = unsettledOnly ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` : sql`` + }${ + subagentFamilyOf === undefined + ? sql`` + : sql` AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent' AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( + SELECT json_extract(member.payload_json, '$.lineage.rootThreadId') + FROM orchestration_v2_projection_threads member + WHERE member.thread_id = ${subagentFamilyOf} + )` + }${ + worktreePath === undefined + ? sql`` + : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` } ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -6095,6 +6186,7 @@ export const layer: Layer.Layer = activityRunStatus: null, activityRunStartedAt: null, pendingRuntimeRequest: null, + pendingGuestMessageCount: pendingGuestMessageCount(thread.guestShare), pendingBackgroundTasks: derivePendingBackgroundWork({ latestRun: latestRunId === null || status === "idle" @@ -6219,6 +6311,7 @@ export const layer: Layer.Layer = ? null : DateTime.makeUnsafe(row.latest_run_completed_at); let latestRunStopRequested = row.latest_run_stop_requested === 1; + let latestRunSilent = row.latest_run_silent === 1; if (row.blocking_run_id !== null && row.blocking_failure_payload_json !== null) { const blockingFailure = yield* decodeTurnItemPayload( row.blocking_failure_payload_json, @@ -6232,6 +6325,7 @@ export const layer: Layer.Layer = terminalFailureItem = blockingFailure; latestRunId = RunId.make(row.blocking_run_id); latestRunStopRequested = row.blocking_run_stop_requested === 1; + latestRunSilent = false; latestRunStatus = "failed"; latestRunRequestedAt = row.blocking_run_requested_at === null @@ -6272,6 +6366,7 @@ export const layer: Layer.Layer = latestRunStartedAt, latestRunCompletedAt, latestRunStopRequested, + latestRunSilent, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun: @@ -6470,6 +6565,18 @@ export const layer: Layer.Layer = Effect.mapError(shellSnapshotReadError), ); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( + shareId: GuestShareId, + ) { + const rows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM orchestration_v2_projection_threads + WHERE json_extract(payload_json, '$.guestShare.id') = ${shareId} + LIMIT 1 + `; + return rows[0] === undefined ? null : ThreadId.make(rows[0].thread_id); + }, Effect.mapError(shellSnapshotReadError)); + const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) => sql .withTransaction( @@ -6565,6 +6672,7 @@ export const layer: Layer.Layer = listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), readShellSnapshot: readOnReader(readShellSnapshot), getThreadShell: readOnReader(getThreadShell), + findGuestShareThreadId: readOnReader(findGuestShareThreadId), getThread: readOnReader(getThread), getSettlementCandidates: readOnReader(getSettlementCandidates), getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests), @@ -6653,6 +6761,22 @@ export const layerMemory: Layer.Layer = Layer.effect( ) { return false; } + if (options?.subagentFamilyOf !== undefined) { + const member = existing.get(options.subagentFamilyOf)?.thread; + if ( + member === undefined || + projection.thread.lineage.relationshipToParent !== "subagent" || + projection.thread.lineage.rootThreadId !== member.lineage.rootThreadId + ) { + return false; + } + } + if ( + options?.worktreePath !== undefined && + projection.thread.worktreePath !== options.worktreePath + ) { + return false; + } if (options?.location === "active") return projection.thread.archivedAt === null; if (options?.location === "archive") return projection.thread.archivedAt !== null; return true; @@ -6696,6 +6820,15 @@ export const layerMemory: Layer.Layer = Layer.effect( .pipe(Effect.map(threadShellFromProjection)); return shell.deletedAt === null ? shell : null; }), + findGuestShareThreadId: Effect.fn("ProjectionStore.findGuestShareThreadId")( + function* (shareId) { + const existing = (yield* Ref.get(replayState)).projections; + for (const { thread } of existing.values()) { + if (thread.guestShare?.id === shareId) return thread.id; + } + return null; + }, + ), getThread: Effect.fn("ProjectionStore.getThread")(function* (threadId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) { @@ -6909,6 +7042,11 @@ export const layerMemory: Layer.Layer = Layer.effect( ...projection.messages.flatMap((message) => message.attachments.map((attachment) => attachment.id), ), + ...(projection.thread.guestShare?.messages ?? []).flatMap((message) => + message.status === "pending" + ? (message.attachments ?? []).map((attachment) => attachment.id) + : [], + ), ...threadHtmlRenderAttachmentIds( threadId, projection.turnItems.flatMap((item) => @@ -7340,21 +7478,26 @@ export const layerMemory: Layer.Layer = Layer.effect( return yield* new ProjectionStoreThreadNotFoundError({ threadId }); return yield* Effect.try({ try: () => { + const visible = projection.visibleTurnItems.map((row) => ({ + ...row, + item: projectTurnItemForWire(row.item), + })); const page = selectHistoryPageFromCursor({ - items: projection.visibleTurnItems.map((row) => ({ - ...row, - item: projectTurnItemForWire(row.item), - })), + items: visible, cursor, throughEntryId, snapshotSequence, }); + const items = [ + ...missingInterruptRequests(visible, page.items), + ...page.items, + ].map((row, position) => ({ ...row, position })); return { ...page, snapshotSequence, items: conversationOnly - ? page.items.filter((row) => isConversationHistoryItem(row.item)) - : page.items, + ? items.filter((row) => isConversationHistoryItem(row.item)) + : items, }; }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), @@ -7405,7 +7548,16 @@ export const layerMemory: Layer.Layer = Layer.effect( ? rawStart : anchors.at(-anchorLimit)! : Math.max(0, anchorIndex - options.rowLimit); - const visibleTurnItems = candidates.slice(start); + const latestTurnRows = anchors.length > 0 ? anchorIndex - anchors.at(-1)! : 0; + const rowLimit = + options.rowLimit > 0 && + latestTurnRows > 0 && + latestTurnRows <= THREAD_HISTORY_PAGE_POLICY.maxTurnItems + ? THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT + : options.rowLimit; + const visibleTurnItems = candidates.slice( + Math.max(start, anchorIndex - Math.max(0, rowLimit)), + ); return { ...snapshot, projection: { ...snapshot.projection, visibleTurnItems }, diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 6e2abc0c1d6b..66d39e1c150d 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -178,9 +178,6 @@ describe("ProviderContinuationService", () => { readonly text: string; readonly notification: OrchestrationV2Notification; }; - // An app-owned child buffers nothing in the adapter, so this text is - // the whole wake. Marking it "provider" would make ClaudeAdapterV2 - // drop it and settle the turn having prompted nothing. assert.notEqual(command.creationSource, "provider"); assert.equal(command.creationSource, "server"); assert.equal(command.text, "Delegated task completed."); @@ -349,11 +346,7 @@ describe("ProviderContinuationService", () => { ), ); - // Mirrors the ACP adapter's own dispatchIfCurrent: on a failed effect - // it clears its sticky "continuation requested" flag before - // re-raising. If this service pre-recovered the failure before - // handing the effect here, this flag would stay stuck set and - // hasPendingBackgroundWork would never clear. + // ACP clears its sticky continuation flag on raw dispatch failure; pre-recovery would prevent that cleanup. const dispatchIfCurrent = (effect: Effect.Effect) => Effect.gen(function* () { const exit = yield* Effect.exit(effect); @@ -402,11 +395,7 @@ describe("ProviderContinuationService", () => { ), ); - // Mirrors the Muse adapter: dispatchIfCurrent forwards the dispatch - // effect without any failure handling of its own (unlike ACP or Pi), - // so only the explicit clearIfCurrent callback can clear the sticky - // "wake held" flag. Without it, a disabled instance leaves the flag - // set forever and no later background turn is ever offered. + // Muse forwards dispatch failures unchanged; clearIfCurrent must release its sticky wake flag. const dispatchIfCurrent = (effect: Effect.Effect) => Effect.asSome(effect); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 8aad65111d09..5435130e689c 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -201,18 +201,10 @@ export const layer = Layer.effectDiscard( attachments: [], dispatchMode: { type: "queue_after_active" }, createdBy: "agent", - // "provider" marks an adapter-buffered wake, which ClaudeAdapterV2 - // detects to attach the buffered CLI output and drop this text. A - // message_text wake has no buffered output, so it must not carry that - // marker or the turn settles immediately having prompted nothing. + // Only buffered wakes use creationSource "provider"; Claude would drain an empty buffer instead of prompting message_text. creationSource: request.delivery === "message_text" ? "server" : "provider", }); - // This catch sits outside dispatchIfCurrent so an adapter that clears - // its own sticky offer flag on dispatch failure (inside - // dispatchIfCurrent) observes the raw failure first. Not every adapter - // self-clears that way, so the explicit callback is called here too; - // clearIfCurrent implementations are idempotent, so a source that - // already cleared is unaffected. + // Catch after dispatchIfCurrent so adapters see raw failures first; clearIfCurrent is idempotent and covers others. const logInstanceDisabled = ( error: ThreadManagementService.ThreadManagementProviderInstanceDisabledError, ) => diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 62a0dace3118..d4f42b3f9326 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -17,21 +17,24 @@ import { type OrchestrationV2TurnItem, ProviderDriverKind, ProviderInstanceId, - type ProviderThreadId, + ProviderThreadId, ProviderTurnId, RunAttemptId, RunId, RuntimeRequestId, TurnItemId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlError from "effect/sql/SqlError"; import * as Stream from "effect/Stream"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; @@ -66,25 +69,63 @@ const layerGit = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); -const layerTest = Layer.mergeAll( - layerTestDatabase, - layerTestStores, - layerTestEventSink, - IdAllocator.layer, - ThreadCommandExecutor.layer, - layerGit, - ProviderEventIngestor.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerTestStores, - layerTestEventSink, - IdAllocator.layer, - ThreadCommandExecutor.layer, - layerGit, +const makeLayerTest = (ingestorEventSink: Layer.Layer) => + Layer.mergeAll( + layerTestDatabase, + layerTestStores, + layerTestEventSink, + IdAllocator.layer, + ThreadCommandExecutor.layer, + layerGit, + ProviderEventIngestor.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerTestStores, + ingestorEventSink, + IdAllocator.layer, + ThreadCommandExecutor.layer, + layerGit, + ), ), ), - ), -); + ); +const layerTest = makeLayerTest(layerTestEventSink); + +// EventSink reports a lock that outlasted busy_timeout at BEGIN IMMEDIATE this way. +const lockTimeout = new EventSink.EventSinkWriteError({ + eventCount: 1, + cause: new SqlError.SqlError({ reason: new SqlError.LockTimeoutError({ cause: null }) }), +}); +const constraintFailure = new EventSink.EventSinkWriteError({ + eventCount: 1, + cause: new SqlError.SqlError({ reason: new SqlError.ConstraintError({ cause: null }) }), +}); + +/** The ingestor's next sink writes of `events` fail with `errors`, in order; the rest go through. */ +const layerFailingWrites = ( + errors: ReadonlyArray, + matches: (events: ReadonlyArray) => boolean = () => true, +) => + makeLayerTest( + Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const pending = [...errors]; + const failNext = ( + events: ReadonlyArray, + write: Effect.Effect, + ) => Effect.suspend(() => (matches(events) ? (pending.shift() ?? write) : write)); + return EventSink.EventSinkV2.of({ + ...eventSink, + write: (input) => failNext(input.events, eventSink.write(input)), + writeIfRunCurrent: (input) => failNext(input.events, eventSink.writeIfRunCurrent(input)), + writeIfProviderThreadOwner: (input) => + failNext(input.events, eventSink.writeIfProviderThreadOwner(input)), + }); + }), + ).pipe(Layer.provide(layerTestEventSink)), + ); const makeSharedWorktree = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -234,6 +275,12 @@ it.effect("records accepted billed turn usage once without billing the context w modelSelection, runtimeMode: "full-access" as const, interactionMode: "default" as const, + origin: { + createdBy: "agent" as const, + creationSource: "provider" as const, + scheduled: false, + subagent: true, + }, }, event: { type: "provider_turn.updated" as const, driver: CODEX_DRIVER, providerTurn }, }; @@ -272,6 +319,10 @@ it.effect("records accepted billed turn usage once without billing the context w mixedModels: false, runtimeMode: "full-access", interactionMode: "default", + createdBy: "agent", + creationSource: "provider", + scheduled: false, + subagent: true, durationMs: 120, }); }).pipe(Effect.provide(layerTest.pipe(Layer.provide(layerAnalytics)))); @@ -1873,7 +1924,6 @@ layer("ProviderEventIngestorV2", (it) => { }, ], }); - // The rename's final scan finishes while the child is still uncommitted. const snapshot = yield* projections.getShellSnapshot(); assert.isFalse( [...snapshot.threads, ...snapshot.archivedThreads].some( @@ -2110,7 +2160,7 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect("moves a native subagent's thread to the model its provider reports later", () => + it.effect("moves a native subagent's thread to its later model through a database lock", () => Effect.gen(function* () { const now = yield* DateTime.now; const eventSink = yield* EventSink.EventSinkV2; @@ -2229,6 +2279,103 @@ layer("ProviderEventIngestorV2", (it) => { (yield* projectionStore.getThread(childThreadId)).modelSelection, reportedSelection, ); - }), + }).pipe( + // Fresh: the shared test layer has already built the ingestor this replaces. + Effect.provide( + Layer.fresh( + layerFailingWrites([lockTimeout, lockTimeout], (events) => + events.some((event) => event.type === "thread.model-selection-updated"), + ), + ), + ), + ), ); }); + +const ingestCompletedProviderTurn = ( + guard: Pick< + Parameters[0], + "writeIfRunCurrent" | "writeIfProviderThreadOwner" + >, +) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [threadEvent] }); + const providerTurnId = ProviderTurnId.make("provider-turn:locked"); + const exit = yield* ingestor + .ingestNormalized({ + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + }), + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + event: { + type: "provider_turn.updated", + driver: CODEX_DRIVER, + providerTurn: { + id: providerTurnId, + providerThreadId: ProviderThreadId.make("provider-thread:locked"), + nodeId: NodeId.make("node:locked-turn"), + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + ...guard, + }) + .pipe(Effect.exit); + const projection = yield* projectionStore.getThreadProjection(threadEvent.threadId); + return { exit, providerTurnId, persisted: projection.providerTurns.map((turn) => turn.id) }; + }); + +// Neither guard matches a stored run, so a guarded write correctly commits nothing. +const runId = RunId.make("run:locked"); +const activeAttemptId = RunAttemptId.make("attempt:locked"); +it.effect.each([ + { route: "write", guard: {} }, + { + route: "writeIfRunCurrent", + guard: { writeIfRunCurrent: { runId, activeAttemptId, expectedStatus: "running" } }, + }, + { + route: "writeIfProviderThreadOwner", + guard: { + writeIfProviderThreadOwner: { + providerThreadId: ProviderThreadId.make("provider-thread:locked"), + runId, + activeAttemptId, + expectedLastRunOrdinal: 1, + }, + }, + }, +] as const)("a provider event $route outlasts a brief database lock", ({ route, guard }) => + Effect.gen(function* () { + const { exit, providerTurnId, persisted } = yield* ingestCompletedProviderTurn(guard); + assert.isTrue(Exit.isSuccess(exit)); + assert.deepEqual(persisted, route === "write" ? [providerTurnId] : []); + }).pipe(Effect.provide(layerFailingWrites([lockTimeout, lockTimeout]))), +); + +// The write after the queued errors would succeed, so failing proves the retries stopped. +it.effect.each([ + { failure: "stays locked", errors: [lockTimeout, lockTimeout, lockTimeout] }, + { failure: "fails for another reason", errors: [constraintFailure] }, +])("fails a provider event write that $failure", ({ errors }) => + Effect.gen(function* () { + const { exit, persisted } = yield* ingestCompletedProviderTurn({}); + assert.instanceOf( + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined, + ProviderEventIngestor.ProviderEventPublishError, + ); + assert.isEmpty(persisted); + }).pipe(Effect.provide(layerFailingWrites(errors))), +); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 401b3332f10a..aa882a863626 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -9,6 +9,8 @@ import { type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type ModelSelection, + type OrchestrationV2Actor, + type OrchestrationV2CreationSource, type RuntimeMode, type ProviderInteractionMode, ProviderInstanceId, @@ -25,6 +27,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlError from "effect/sql/SqlError"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; @@ -68,6 +71,14 @@ export class ProviderEventPublishError extends Schema.TaggedError + SqlError.isSqlError(error.cause) && error.cause.reason._tag === "LockTimeoutError"; +const retryLockTimeout = (write: Effect.Effect) => + Effect.retry(write, { while: isLockTimeout, times: 2 }); + export const ProviderEventIngestorV2Error = Schema.Union([ ProviderEventNormalizeError, ProviderEventPublishError, @@ -78,6 +89,19 @@ export interface ProviderTurnAnalyticsContext { readonly modelSelection: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; + readonly origin?: ProviderTurnOrigin; +} + +/** + * Where the run's starting message came from. Most turns start without a + * person (provider continuations, agent sends, delegated subagents), so + * reports need this to tell user turns apart. + */ +export interface ProviderTurnOrigin { + readonly createdBy: OrchestrationV2Actor; + readonly creationSource: OrchestrationV2CreationSource; + readonly scheduled: boolean; + readonly subagent: boolean; } export class ProviderTurnAnalytics extends Context.Reference<{ @@ -127,6 +151,7 @@ function providerTurnAnalyticsProperties(input: { ...(effort ? { effort } : {}), ...(input.context?.runtimeMode ? { runtimeMode: input.context.runtimeMode } : {}), ...(input.context?.interactionMode ? { interactionMode: input.context.interactionMode } : {}), + ...input.context?.origin, ...(input.providerTurn.startedAt && input.providerTurn.completedAt ? { durationMs: Math.max( @@ -391,12 +416,7 @@ export const layer: Layer.Layer< }, ); - /** - * A native subagent's thread starts on the parent's model when the provider - * names the real one later (a Claude agent file's model arrives with the - * subagent's first reply). Thread commands rewrite the whole thread row - * under the thread's lock, so this read and write take it too. - */ + /** Providers may reveal native subagent models late; lock full-row updates against concurrent thread commands. */ const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { const { childThreadId, model } = subagent; @@ -435,18 +455,13 @@ export const layer: Layer.Layer< }, occurredAt: now, }); - return yield* eventSink.write({ events: [event] }); + return yield* eventSink.write({ events: [event] }).pipe(retryLockTimeout); }), ); }, ); - /** - * Retire the runs that fell off its active branch with the same run, - * root-node, and checkpoint updates CheckpointRollbackService writes for a - * T3 revert. The run reporting the rewind performed it and stays, so it - * keeps lastRunOrdinal. - */ + /** Retire rewound runs like CheckpointRollbackService; retain the reporting run and its lastRunOrdinal. */ const rollBackRewoundRuns = Effect.fn("ProviderEventIngestor.rollBackRewoundRuns")(function* ( input: ProviderEventIngestInput, rewind: { @@ -760,7 +775,7 @@ export const layer: Layer.Layer< ...input.writeIfProviderThreadOwner, events, }) - .pipe(Effect.mapError(mapWriteError)); + .pipe(retryLockTimeout, Effect.mapError(mapWriteError)); return ownerResult.storedEvents; } if (input.writeIfRunCurrent === undefined) { @@ -770,7 +785,7 @@ export const layer: Layer.Layer< ...(input.commandId === undefined ? {} : { commandId: input.commandId }), events, }) - .pipe(Effect.mapError(mapWriteError)); + .pipe(retryLockTimeout, Effect.mapError(mapWriteError)); } const result = yield* eventSink .writeIfRunCurrent({ @@ -780,7 +795,7 @@ export const layer: Layer.Layer< ...input.writeIfRunCurrent, events, }) - .pipe(Effect.mapError(mapWriteError)); + .pipe(retryLockTimeout, Effect.mapError(mapWriteError)); return result.storedEvents; }, (effect, input) => diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index a3c41fed25f5..f84346192569 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -175,11 +175,7 @@ export const make = Effect.gen(function* () { const ids = yield* IdAllocator.IdAllocatorV2; const outbox = yield* EffectOutbox.EffectOutboxV2; type RecoverySession = ProjectionStore.ProjectionRuntimeRecoveryState["providerSessions"][number]; - /** - * A session shared by several threads (one Codex app server) is stopped by the - * first thread recovery reaches. It was live when the server went down, so a - * later thread still decides its wake as if it were. - */ + /** Shared sessions stop on their first recovered thread; later threads still decide wakes from pre-recovery liveness. */ const sessionsLiveAtStart = ( projection: ProjectionStore.ProjectionRuntimeRecoveryState, stoppedThisPass: ReadonlyMap, @@ -440,8 +436,7 @@ export const make = Effect.gen(function* () { candidate.status === "running" || candidate.status === "waiting"), )) { - // A native subagent does not come back when its cut turn resumes, so the - // turn's continuation is told to restart it. + // Cut native subagents do not resume; record them for the continuation to restart. if (run.status === "waiting") recordCancelledBackgroundItem(item); else if (item.type === "subagent" && item.origin === "provider_native") recordCancelledBackgroundItem(item); @@ -457,9 +452,7 @@ export const make = Effect.gen(function* () { }); } } - // A blocking delegate_task wait dies with the turn that made it, and its - // continuation never got the tool result. Let the child's result wake - // the parent like an async task, so it reaches the continued turn. + // Blocking waits die with their turn; let child results wake the parent continuation as async tasks. const cutRunIds = new Set(runs.map((run) => run.id)); for (const task of projection.subagents ?? []) { if ( @@ -526,8 +519,7 @@ export const make = Effect.gen(function* () { } } } - // No provider process outlives the server, so a native subagent still open - // here never reports its end, whatever state its run and item are in. + // No provider process survives the server; open native subagents can never report completion. for (const subagent of projection.subagents ?? []) { events.push( ...(yield* endOrphanedNativeSubagent({ @@ -540,8 +532,7 @@ export const make = Effect.gen(function* () { })), ); } - // A provider-native subagent thread has no runs; left running, the child - // would show as working forever. + // Native subagent threads have no runs to settle their runless root turns. events.push( ...(yield* endRunlessRootTurns({ threadId: projection.thread.id, @@ -603,8 +594,7 @@ export const make = Effect.gen(function* () { for (const [providerThreadId, work] of cancelledBackgroundWork) { const noteRun = latestStartedRun(projection, providerThreadId); if (noteRun === undefined) continue; - // Its own event: a run snapshot read before this commit could regress - // a lifecycle change (e.g. a checkpoint completing the run) made since. + // Use a separate event; stale run snapshots can overwrite intervening lifecycle changes. events.push({ id: yield* allocateEventId(), type: "run.background-work-cancelled", diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 8b9c3a108ea1..bc7534761d8f 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -349,9 +349,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( closeCount: current.closeCount + 1, })), ); - // Registered after countClose so it runs first on scope close, wedging the - // close before the closeCount finalizer, like a provider process that never - // yields its message stream. + // Register after countClose to wedge teardown before its finalizer, matching a provider stream that never yields. const hangClose = Effect.addFinalizer(() => (options.scopeCloseReached === undefined ? Effect.void @@ -1303,8 +1301,7 @@ it.effect( () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); - // A's adapter start installs its turn, then is stopped mid-request; like - // OpenCode2, the adapter still ends that turn with turn.terminal later. + // Like OpenCode2, a stopped start can still emit turn.terminal later. const holdStart = yield* Ref.make(false); const startHeld = yield* Deferred.make(); yield* Effect.gen(function* () { @@ -2082,9 +2079,6 @@ it.effect( const originalToken = original?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(originalToken); - // Workspace-change handoff on a shared multi-thread session (codex): - // the thread detaches while the provider process keeps running, and the - // process's MCP client keeps using the credential it was started with. yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); assert.equal( (yield* registry.resolve(originalToken!))?.thread.threadId, @@ -2092,9 +2086,6 @@ it.effect( "detach must not revoke the credential the live provider process still holds", ); - // The continuation run re-attaches the same thread to the same session; - // the credential must be reused, not rotated, so the provider process's - // long-lived MCP client stays authorized. yield* manager.open({ threadId, providerSessionId, @@ -2159,11 +2150,7 @@ it.effect( assert.isDefined(originalToken); yield* manager.detach({ providerSessionId: s1, threadId }); - // While S2's provider process is spawning (after prepare reused the - // credential, before the entry is visible), the predecessor session - // releases. Eager adapters (ACP, OpenCode) bake the credential into - // the process during openSession, so the release must not revoke it; - // rotating afterwards cannot repair those adapters. + // ACP and OpenCode consume credentials during openSession; predecessor release must not revoke them before entry publication. yield* Ref.set(duringOpen, manager.close(s1).pipe(Effect.orDie)); yield* manager.open({ threadId, providerSessionId: s2, modelSelection, runtimePolicy }); @@ -3096,11 +3083,8 @@ it.effect( const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); assert.isDefined(queue); - // A stray terminal arrives for the second (never-busy) provider - // thread, e.g. a turn cancelled moments after it started on another - // app thread sharing this session. With a session-wide busyCount - // this would zero it out and release the session even though the - // first thread's turn is still genuinely running. + // A stray terminal for the never-busy second thread must not zero a session-wide busyCount + // and release the session while the first thread's turn is still running. yield* Queue.offer(queue!, { type: "turn.terminal", driver: CODEX_DRIVER, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index ccab69810426..379f3313ac50 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -191,12 +191,7 @@ export class ProviderSessionManagerV2 extends Context.Service< interface LiveSessionEntry { readonly attachedThreadIds: ReadonlySet; readonly loadedProviderThreadKeyByThread: ReadonlyMap; - /** - * Revocation on detach/release is scoped to these ids so tearing down a - * superseded session cannot revoke a replacement session's credential for the - * same thread (the workspace-handoff sequence opens the replacement before - * the outbox executes the old session's detach). - */ + /** Scope revocation to recorded credential ids so superseded sessions cannot revoke replacements opened before detach. */ readonly mcpCredentialIdByThread: ReadonlyMap; readonly supportsMultipleProviderThreads: boolean; readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; @@ -316,12 +311,7 @@ export const layerWithOptions = ( const fileSystem = yield* FileSystem.FileSystem; const mcpSessionRegistry = yield* McpSessionRegistry.McpSessionRegistry; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - /** - * When present, an unreadable settings file withholds browser access - * rather than granting it — an explicit "off" silently becoming "on" - * would violate the user's stated choice, whereas the reverse costs an - * agent one toolset and is visible immediately (#7083). - */ + /** Unreadable settings must preserve explicit browser-access opt-outs. */ const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); const projectService = yield* Effect.serviceOption(ProjectService.ProjectService); const eventSink = yield* EventSink.EventSinkV2; @@ -369,10 +359,7 @@ export const layerWithOptions = ( }, ); const layerScope = yield* Effect.scope; - // Ctrl+C, or a stop that signals the whole process group, reaches the - // provider CLIs with the server. They report their own background work - // stopped before shutdown captures restart continuations, so provider - // events after the signal are dropped; restart recovery owns that state. + // Group signals stop providers before restart capture; drop post-signal events so recovery owns that state. const shutdownSignal = { received: false }; const onShutdownSignal = () => { shutdownSignal.received = true; @@ -389,8 +376,7 @@ export const layerWithOptions = ( }), ); const sessions = yield* Ref.make(new Map()); - // One retry per released entry, so a later release with the same id - // cannot drop cleanup for threads only the earlier session served. + // Retry per released entry; reusing ids must not lose cleanup for earlier threads. const releaseRecordRetries = yield* FiberSet.make(); const nextSubscriberId = yield* Ref.make(0); const sessionOpen = yield* KeyedLock.make(); @@ -405,11 +391,7 @@ export const layerWithOptions = ( readonly mcpCredentialId: string | undefined; readonly issued: boolean; } - /** - * Adapters like ACP and OpenCode consume the credential eagerly during - * openSession, so a racing release must not revoke it in that window - * (rotating afterwards cannot repair an already-configured process). - */ + /** ACP and OpenCode consume credentials during openSession; reserve them against revocation until publication. */ const mcpCredentialReservations = new Map(); const mcpReservationKey = (threadId: ThreadId, mcpCredentialId: string) => `${threadId}\0${mcpCredentialId}`; @@ -429,10 +411,7 @@ export const layerWithOptions = ( const isMcpCredentialReserved = (threadId: ThreadId, mcpCredentialId: string) => (mcpCredentialReservations.get(mcpReservationKey(threadId, mcpCredentialId)) ?? 0) > 0; const mcpPrepareLock = yield* KeyedLock.make(); - /** - * Serialized per thread so two concurrent prepares cannot interleave - * their rotate steps and revoke each other's freshly minted credential. - */ + /** Serialize prepares per thread so concurrent rotations cannot revoke each other's credentials. */ const prepareMcpSession = ( threadId: ThreadId, providerInstanceId: ProviderInstanceId, @@ -444,12 +423,7 @@ export const layerWithOptions = ( : mcpPrepareLock.withLock( threadId, Effect.gen(function* () { - // Reuse a still-valid credential for this thread instead of - // rotating: long-lived provider processes (codex app-server) - // build their MCP client once per conversation and keep using - // the credential it started with, so a thread that detaches and - // re-attaches across a workspace handoff must come back to the - // same token or the process's tool calls fail auth. + // Live providers build MCP clients once; reuse credentials across workspace handoffs. const { browser: browserToolsAvailable, device: deviceToolsAvailable } = yield* agentAccessSettings(threadId); const capabilities = new Set< @@ -459,12 +433,10 @@ export const layerWithOptions = ( if (deviceToolsAvailable) capabilities.add("device"); const existing = yield* mcpSessions.read(threadId); if (existing !== undefined) { - // Reserve before the async resolve so a release cannot - // revoke the credential between validation and reservation. + // Reserve before async validation so release cannot revoke between validation and reservation. reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); - // The caller only learns of the reservation once this returns, - // so a stop while resolving must drop it here. + // Interrupted resolution must release the reservation before the caller learns of it. const resolved = yield* mcpSessionRegistry .resolve(rawToken) .pipe( @@ -734,11 +706,8 @@ export const layerWithOptions = ( } }); - // Read as soon as the session leaves the live map: after that, a - // replacement session with the same id can open and report tasks of its - // own on the same provider threads. OpenCode's background subagents run - // on a server shared by its sessions and can outlive this one, so their - // roster stays. + // Read tasks as soon as the session leaves the live map, before a replacement reports its own. + // Keep OpenCode's roster: its background subagents share a server and can outlive the session. const readReleasedBackgroundTasks = Effect.fn( "ProviderSessionManager.readReleasedBackgroundTasks", )( @@ -790,8 +759,7 @@ export const layerWithOptions = ( ReturnType >[number]; - // Their process is gone, so nothing will report them ending, and a - // leftover roster keeps the thread Waiting (#15239). + // Dead processes cannot report task endings; stale rosters keep threads Waiting. const writeReleasedBackgroundWorkEvents = ( released: ReadonlyArray, ) => @@ -864,9 +832,7 @@ export const layerWithOptions = ( { concurrency: 1 }, ).pipe(Effect.flatMap(Exit.asVoidAll)); - // The session already left the live map, so a later release finds nothing - // to do. Without a retry the UI would keep a ready session and answerable - // approvals until a server restart. + // Retry after removal from the live map; later releases cannot clear stale ready sessions and approvals. const retryReleaseRecords = ( input: Omit[0], "replaced">, ) => { @@ -886,8 +852,7 @@ export const layerWithOptions = ( providerSessionId, cause: exit.cause, }); - // A failed SQL commit is a defect, so every failure but interruption - // is retried. + // SQL commit failures are defects; retry every cause except interruption. return yield* Effect.fail(exit.cause); }); return attempt.pipe( @@ -1064,20 +1029,13 @@ export const layerWithOptions = ( Option.match(entry, { onNone: () => Effect.void, onSome: (entry) => - // Revoke every credential this session recorded, including for - // threads that detached without re-attaching: the provider - // process is gone, so nothing holds them anymore. Skip threads - // a live replacement session took over, since credential reuse - // means the replacement may hold this very credential. + // Revoke recorded credentials after process death, except those held by live replacements. Ref.get(sessions).pipe( Effect.flatMap((current) => Effect.forEach( entry.mcpCredentialIdByThread, ([threadId, mcpCredentialId]) => { - // Id-sensitive: a stale record for the same thread but - // a DIFFERENT credential (left behind by an old session - // the thread rotated away from) must not veto revoking - // this session's own credential, or it leaks forever. + // Only holders of this credential may prevent its revocation; stale credentials must not leak it. const heldElsewhere = isMcpCredentialReserved(threadId, mcpCredentialId) || Array.from(current.values()).some( @@ -1122,8 +1080,7 @@ export const layerWithOptions = ( ) { return; } - // Capture runtime identity before yielding: a replacement session - // can reuse the same providerSessionId while this fiber is parked. + // Capture runtime identity before yielding; replacements can reuse the session id. const probedRuntime = entry.runtime; const hasPendingWork = probedRuntime.hasPendingBackgroundWork === undefined @@ -1156,9 +1113,7 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, pinnedForMs: now - pinnedSinceMs, }); - // Re-check on this fiber after another idle window. Do not call - // scheduleIdleReleaseInternal: that cancels entry.idleFiber, which - // is this fiber, and can self-deadlock on Fiber.interrupt. + // Recheck on this fiber; scheduleIdleReleaseInternal would interrupt it and self-deadlock. yield* Effect.sleep(Duration.millis(idleTimeoutMs)); return yield* releaseIfStillIdle(input); } @@ -1167,10 +1122,7 @@ export const layerWithOptions = ( pinnedForMs: now - pinnedSinceMs, }); } - // hasPendingBackgroundWork yields to the adapter, so the idle - // decision above can go stale; the generation guard revalidates - // busyTurns and idleGeneration inside releaseEntry's atomic - // entry removal. + // Adapter probes yield; guard busyTurns and idleGeneration atomically when removing the entry. yield* releaseEntry({ providerSessionId: input.providerSessionId, reason: "idle_timeout", @@ -1363,13 +1315,10 @@ export const layerWithOptions = ( dropMcpCredentialReservation(input.threadId, preparedForCleanup.mcpCredentialId); } }; - // The whole attach, including undoing a failed one, holds the - // thread's lock: a concurrent attach of the same thread waits, so it - // never sees an attachment that this call is about to roll back. + // Lock attach through rollback so concurrent attaches cannot observe an attachment about to be undone. const attach = Effect.gen(function* () { const attached = yield* attachThread(input).pipe( - // Recorded with no gap for an interrupt: cleanup undoes only an - // attach this call made, never one an earlier open made. + // Record without interruption so cleanup undoes only this call's attachment. Effect.tap((runtime) => Effect.sync(() => (attachedTo = runtime))), Effect.uninterruptible, ); @@ -1410,10 +1359,7 @@ export const layerWithOptions = ( Effect.andThen( Effect.suspend(() => { dropReservation(); - // Revoke only a credential this attach freshly minted: a - // REUSED credential is by definition held by another - // live provider process, and revoking it thread-wide - // would break that process's MCP client mid-conversation. + // Other live processes hold reused MCP credentials; revocation would break their clients. if (preparedForCleanup?.issued !== true) return Effect.void; const mcpCredentialId = preparedForCleanup.mcpCredentialId; const attachedRuntime = attachedTo; @@ -1606,8 +1552,7 @@ export const layerWithOptions = ( ) { return "skipped" as const; } - // Unloading stops the native thread's background terminals, so a - // thread still running background work stays loaded. + // Unloading kills background terminals; keep threads with pending work loaded. const hasPendingWork = entry.runtime.hasPendingBackgroundWorkForThread === undefined ? false @@ -1652,8 +1597,7 @@ export const layerWithOptions = ( return "unloaded" as const; }), ); - // Re-check on this fiber after another idle window, outside the - // lock so the thread's next attach is not held up meanwhile. + // Wait outside the lock so the next attach is not blocked. if (outcome === "deferred") { yield* Effect.sleep(Duration.millis(idleTimeoutMs)); return yield* unloadIdleThread(input); @@ -1844,8 +1788,7 @@ export const layerWithOptions = ( providerInstanceId: runtime.instanceId, }), ).pipe( - // A start that fails or is stopped may never emit turn.terminal, - // so it clears its own turn or the session never goes idle. + // Failed or stopped starts may omit turn.terminal; clear their turns so sessions can idle. Effect.andThen( holdThreadLoaded({ providerSessionId, @@ -1932,12 +1875,10 @@ export const layerWithOptions = ( return entry.runtime.events.pipe( Stream.runForEach((event) => { if (shutdownSignal.received) return Effect.void; - // Live output skips run subscribers and ingestion entirely: it is - // never persisted, and a flood of it must not queue ahead of real events. + // Live output bypasses persistence and subscribers so floods cannot delay domain events. if (event.type === "command_output.delta") { return commandOutput.append(event); } - // A settled item carries its final output, so its live tail can go. const releaseLiveOutput = event.type === "turn_item.updated" && event.turnItem.type === "command_execution" && @@ -2071,8 +2012,7 @@ export const layerWithOptions = ( ); }; - // The service launcher kills the server 5 s after it asks it to stop, and - // a busy server can hold 15 sessions. + // Shutdown must fit the launcher's 5 s deadline with up to 15 sessions. const sessionScopes = yield* Scope.make("parallel"); const shutdown = Effect.gen(function* () { const activeSessions = [...(yield* Ref.get(sessions)).values()]; @@ -2207,10 +2147,7 @@ export const layerWithOptions = ( }) .pipe( Effect.provideService(Scope.Scope, sessionScope), - // The session cleanup runs first, and the close is - // time-boxed: this runs under the session's open lock, so an - // adapter finalizer that never finishes must not hold the - // interrupter, the lock, or later opens. + // Bound scope close after cleanup; a wedged finalizer must not hold the open lock or interrupter. Effect.onError(() => dropReservation.pipe( Effect.andThen( @@ -2281,8 +2218,7 @@ export const layerWithOptions = ( payload: runtime.providerSession, }), ).pipe( - // Released on interrupt too: this entry has no event pump or - // idle timer yet, so nothing else would ever release it. + // Release on interruption too; no event pump or idle timer exists to clean up yet. Effect.onError((cause) => releaseEntry( Cause.hasInterruptsOnly(cause) @@ -2467,10 +2403,7 @@ export const layerWithOptions = ( yield* cancelIdleFiber(detachResult.value.idleUnloadFiber); } const detached = Option.map(detachResult, (result) => result.entry); - // Plain detaches deliberately do not revoke: a detached thread's - // provider process may still be alive (shared multi-thread codex - // session across a workspace handoff) and holds its MCP client's - // credential for the thread it will re-attach with. + // Plain detaches preserve credentials held by live shared providers across workspace handoffs. if (input.revokeMcpCredential === true) { yield* clearMcpSession(input.threadId); } @@ -2491,9 +2424,7 @@ export const layerWithOptions = ( } const unloadThread = detached.value.exposedRuntime.unloadThread; if (detached.value.supportsMultipleProviderThreads && unloadThread !== undefined) { - // Serialized with re-attachment: a thread whose next turn - // attaches first stays loaded, and one that attaches during the - // unload waits for it, so its resume reloads the native thread. + // Serialize unload with attachment so attached threads stay loaded and later attaches resume after unload. yield* threadAttachment.withLock( threadAttachmentKey(input), Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts index 784d03de6dcb..1ccc7f9f4f76 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts @@ -91,6 +91,6 @@ export function decideProviderSessionTransition(input: { } } - // Interaction mode is turn-scoped and is applied when the next turn starts. + // Apply interaction mode on the next turn. return { type: "reuse" }; } diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.ts index ed343e13e7f5..6e10b2fdf02a 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.ts @@ -181,8 +181,7 @@ export const layer: Layer.Layer< .filter((session) => { if (!isLiveProviderSession(session)) return false; if (transition.type === "restart_and_resume") { - // Other live records may serve pooled or delegated bindings; - // only the session being replaced is released. + // Release only the replaced session; peers can serve pooled or delegated bindings. return session.id === currentSession?.id; } if (transition.type === "create_with_handoff") { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 3ae3a9974b48..42646e974ec0 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -214,6 +214,7 @@ it.effect( listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), + findGuestShareThreadId: () => Effect.die("unused findGuestShareThreadId"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), getThreadsWithPullRequests: () => Effect.die("unused getThreadsWithPullRequests"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 56bbae37dd91..3a5bc04ecba2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -213,16 +213,13 @@ export const layer: Layer.Layer< : { requestedAt: input.subagent.requestedAt }), }); } - // A settled turn reaches its adapter too: only the adapter knows - // whether it still runs work for the thread, and each one either - // stops it or reports there is nothing left to stop. + // Settled turns still reach adapters, which alone know whether background work remains. yield* session.value.interruptTurn({ providerThread: loaded.providerThread, providerTurnId: loaded.providerTurn.id, requestRuntimeRestart: true, }); - // The wait is real time: ingestion runs on other fibers and never - // advances a test clock, so a test clock would hold Stop forever. + // Use real time; ingestion fibers do not advance TestClock and would leave Stop blocked. yield* Effect.gen(function* () { const deadline = (yield* Clock.currentTimeMillis) + 2_000; while ( @@ -258,9 +255,7 @@ export const layer: Layer.Layer< function* (input) { const loaded = yield* load({ ...input, operation: "restart" }); if (Option.isNone(loaded.session)) { - // No live adapter: nothing can emit a terminal provider-turn update - // from interrupt. Do not poll for projection terminalization or the - // restart effect stalls; let detach/start proceed. + // Without a live adapter, no terminal event can arrive; proceed to detach/start without polling. if (loaded.providerTurn.status === "running") { yield* Effect.logWarning( "Provider restart interrupt skipped; no live session for a still-projected running turn", @@ -296,10 +291,7 @@ export const layer: Layer.Layer< ) { return; } - // Provider terminal events are projected on a detached ingestion - // fiber. Yield through the Node event loop instead of sleeping on - // Effect's clock so deterministic runtimes cannot deadlock a - // command that is waiting for that projection. + // Yield through Node's event loop; detached ingestion cannot advance a deterministic Effect clock. yield* yieldToRuntime; } return yield* new ProviderTurnControlError({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index e079157286cb..8b5a3a93cb91 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -90,7 +90,6 @@ const layerFakeFileSystem = (realPaths: Record) => ), } as never); -/** Starts a run on a thread whose worktree folder is missing, with routing reads failing after. */ function startWithMissingWorktree( worktreePath: string, realPaths: Record = defaultRealPaths, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 95be01ff77e3..4bc18d896b11 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -132,8 +132,7 @@ export const layer: Layer.Layer< const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2; const isManagedWorktreePath = yield* makeIsManagedWorktreePath; - // These callbacks outlive startup while a run drains background work. Build - // them outside start's scope so they cannot retain its full thread history. + // Build draining callbacks outside startup scope so they cannot retain full thread history. const makeRunControls = (input: { readonly threadId: ThreadId; readonly runId: RunId; @@ -435,8 +434,7 @@ export const layer: Layer.Layer< if (message.attachments.length === 0 && message.text.trimStart().startsWith("/")) { const isEmptyCompaction = message.text.trim().toLowerCase() === "/compact" && !projection.hasConversation; - // Preparing a run may already point the thread at a newly selected - // provider. Account commands still belong to its last native session. + // Preparation may select a new provider; account commands still target the last native session. const nativeThreads = new Map( projection.providerThreads .filter( @@ -737,10 +735,7 @@ export const layer: Layer.Layer< ); } if (providerThread.nativeThreadRef === null) { - // Hand the run's provider thread to the adapter so it adopts this - // row's identity when attaching native state. An adapter that mints - // its own row instead leaves two live rows per app thread, and - // `activeProviderThreadId` then flaps between them on every update. + // Pass the stored provider row for adoption; adapter-minted duplicates make activeProviderThreadId flap. return yield* loadFromProvider( session.ensureThread({ threadId: projection.thread.id, @@ -794,9 +789,7 @@ export const layer: Layer.Layer< modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, - // The native ref is dropped so the adapter binds a fresh native - // session instead of retrying the resume that just failed, while - // still adopting this row's identity. + // Clear the native ref to force a fresh session while preserving row identity. existingProviderThread: { ...providerThread, nativeThreadRef: null }, }), ); @@ -1038,8 +1031,7 @@ export const layer: Layer.Layer< text: message.text, records: message.context?.records ?? [], }); - // Delivered once: this run's provider turn marks the work as told. A - // restart continuation is prompted by its own text or resumes natively. + // The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively. const noteContinuation = isRestartNoteContinuation( run, projection.runs, @@ -1117,8 +1109,7 @@ export const layer: Layer.Layer< ) .map((source) => source.id), ); - // Legacy attempts lack native identity; exclude their explicitly recovered history, whose - // attachments were not replayed into the replacement thread. + // Legacy attempts lack native ids; exclude recovered attachments absent from the replacement thread. const nativeContextEstimate = Effect.gen(function* () { return sameNativeThread ? (yield* projectionStore.getTurnStartHistory(input.threadId)).reduce((sum, item) => { @@ -1257,8 +1248,7 @@ export const layer: Layer.Layer< text: context === "" ? userText : `${context}\n\nUser message:\n${userText}`, }, }).pipe( - // A pending marker would make the next turn abandon this native - // session, though the refused prompt never reached it. + // Clear pending handoff markers for refused prompts so the next turn retains the native session. Effect.tapError((error) => refusedBeforePrompt(error) ? delivery.unsent.pipe( @@ -1271,8 +1261,7 @@ export const layer: Layer.Layer< : Effect.void, ), ); - // The provider already accepted the turn. A stale pending marker - // can force a fresh thread later, but must not stop live ingestion. + // After provider acceptance, failed delivery bookkeeping must not stop live ingestion. yield* delivery.delivered.pipe( Effect.catchCause(() => Effect.logWarning("Failed to record accepted context handoff delivery", { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 54f3e4b25553..184a0295b653 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -859,7 +859,6 @@ describe("PullRequestSyncReactor", () => { yield* Effect.gen(function* () { const reactor = yield* startAndSweep(fixture); const firstSweep = yield* Ref.get(fixture.syncCommands); - // The held merged summary is dropped so the read reaches the host. assert.deepStrictEqual(yield* Ref.get(invalidated), [1]); assert.strictEqual(firstSweep.at(-1)?.snapshot.mergedAt, "2026-08-27T00:00:00.000Z"); yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep)); @@ -1337,7 +1336,11 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("reads open links fresh only when a run that ran a merge command ends", () => + it.effect.each([ + "gh pr merge 7 --squash 2>&1 | tail -3", + "cafe pr merge 7 --squash", + "az repos pr update --id 7 --status completed", + ])("reads open links fresh only when a run that ran `%s` ends", (mergeCommand) => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1366,7 +1369,7 @@ describe("PullRequestSyncReactor", () => { yield* Queue.offerAll(fixture.domainEvents, [ commandRan(ThreadId.make("other"), "gh pr view 9"), runUpdated(ThreadId.make("other"), "completed"), - commandRan(ThreadId.make("agent"), "gh pr merge 7 --squash 2>&1 | tail -3"), + commandRan(ThreadId.make("agent"), mergeCommand), runUpdated(ThreadId.make("agent"), "completed"), ]); yield* Queue.take(fixture.snapshotReads); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index af7ecdadb12c..e2be84c5663d 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,4 +1,3 @@ -import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, type PullRequestSummary, @@ -14,7 +13,8 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -39,10 +39,14 @@ import { isTerminalRunStatus } from "./ThreadManagementService.ts"; const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; // Every server polls every open linked pull request, and most of them sit untouched for days. // One nobody has updated for QUIET_AFTER_MS is read on the quiet cadence instead of each sweep; -// requested refreshes, watches and `gh pr merge` still read it at once. +// requested refreshes, watches and a host CLI's merge command still read it at once. const QUIET_AFTER_MS = 30 * 60 * 1_000; const QUIET_SYNC_INTERVAL_MS = 5 * 60 * 1_000; -const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; + +const isChangeRequestCloseCommand = (command: string) => + sourceControlDefinitions.definitions.some( + (definition) => definition.changeRequestCloseCommand?.test(command) === true, + ); const isPullRequestProviderError = Schema.is(PullRequestProviderError); @@ -53,6 +57,20 @@ interface LinkEntry { readonly link: ThreadPullRequestLink; } +/** Another layer of the stack `url` belongs to, in its host's canonical shape. */ +function siblingChangeRequestUrl(url: string, number: number): string | null { + if (!Number.isSafeInteger(number) || number < 1) return null; + const parsed = sourceControlDefinitions.parseChangeRequestUrl(url); + if (parsed === null) return null; + const { definition, link } = parsed; + return definition.changeRequestUrl({ + host: link.host, + repository: link.repository, + number, + webUrl: link.repositoryUrl, + }); +} + function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { return { state: summary.state, @@ -164,15 +182,12 @@ export class PullRequestSyncReactor extends Context.Service< { readonly start: () => Effect.Effect; readonly drain: Effect.Effect; - /** - * Force the next sweep to re-read this pull request, even when its snapshot is terminal. - * While its host is rate limited, the read waits for the first sweep after the pause. - */ + /** Forces reread on the next sweep, including terminal snapshots; rate-limited hosts wait until the pause ends. */ readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect; } >()("t3/orchestration-v2/PullRequestSyncReactor") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const engine = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -188,17 +203,14 @@ export const make = Effect.gen(function* () { // linking dozens of pull requests) is read together and shares the summary batches. let requestedSweepQueued = false; const retryStacks = new Set(); - // Rate limit pauses by project and host, since each project reads with its own credential. - // A paused host refuses every read without asking it, so the sweep leaves its pull requests - // due until the pause ends rather than failing each of them every minute. + // Pause per project and host because credentials are project-specific; leave paused PRs due until reset. const pausedUntil = new Map(); const isDue = (key: string, entries: ReadonlyArray, nowMs: number): boolean => { if (requested.has(key) || retryStacks.has(key)) return true; if (entries.some((entry) => entry.link.snapshot === null)) return true; const active = entries.filter((entry) => isUnsettled(entry.thread)); - // A merged snapshot without its merge time cannot settle the thread, so it keeps the slow - // cadence until the host supplies one (Bitbucket snapshots saved before it was read). + // Merged snapshots without mergedAt cannot settle threads; reread slowly until supplied. if ( active.every( (entry) => entry.link.snapshot?.state === "merged" && entry.link.snapshot.mergedAt != null, @@ -268,9 +280,7 @@ export const make = Effect.gen(function* () { link.snapshot === null || !snapshotFieldsEqual(link.snapshot, fields) || !stacksEqual(link.stack, nextStack); - // Persist discovered siblings before a terminal snapshot can trigger settlement. A settled - // thread that shares this pull request with an active one takes the fresh snapshot, but - // gains no links. + // Persist siblings before terminal snapshots trigger settlement; settled threads get snapshots without new links. for (const layer of isUnsettled(thread) ? (fetchedStack?.stack?.layers ?? []) : []) { const layerKey = { host: normalizeThreadPullRequestKey(link).host, @@ -287,7 +297,7 @@ export const make = Effect.gen(function* () { ) { continue; } - const url = siblingPullRequestUrl(link.url, layer.number); + const url = siblingChangeRequestUrl(link.url, layer.number); if (url === null) continue; const uuid = yield* crypto.randomUUIDv4(); yield* engine.dispatch({ @@ -384,11 +394,8 @@ export const make = Effect.gen(function* () { } const fetched = fetchedStack.stack; stacksReadThisSweep.set(key, fetched); - // A layer pushed above an older member changes none of that member's own fields, so it - // would keep its shorter layer list. Members of this stack whose layer list differs read - // their stack once, in this sweep if still pending or the next; agreeing ones read none. - // Only groups of the same project: another project reads with its own credential and - // may see a different layer list, so flagging across projects would never settle. + // New upper layers leave older members' fields unchanged; differing layer lists need a stack read. + // Only flag siblings in the same project: different credentials can yield lists that never agree. if (fetched !== null) { const fetchedKey = nativeStackKeyOf(first.link, fetched); for (const [siblingKey, siblingEntries] of groups) { @@ -450,8 +457,7 @@ export const make = Effect.gen(function* () { } const first = entries[0]!; const pauseKey = `${first.thread.projectId}\0${normalizeThreadPullRequestKey(first.link).host}`; - // Checked against the clock as each read starts, so a pause found earlier in this sweep - // holds the rest, and one that ends during the sweep lets the rest through. + // Check pauses at each read so changes during the sweep apply immediately. return Clock.currentTimeMillis.pipe( Effect.flatMap((startedAtMs) => (pausedUntil.get(pauseKey) ?? 0) > startedAtMs @@ -460,8 +466,7 @@ export const make = Effect.gen(function* () { ), ); }, - // As wide as one batched summary read, so the sweep's reads on a host arrive together and - // GitHub answers them in one request rather than one `gh pr view` apiece. + // Match summary batch width so GitHub reads coalesce into one request. { concurrency: 25, discard: true }, ); for (const [reason, { count, key }] of skips) { @@ -519,14 +524,14 @@ export const make = Effect.gen(function* () { requestSync, { discard: true }, ); - // An agent can merge or close its pull request from a shell (`gh pr merge`), which + // An agent can merge or close its pull request from a shell with its host's CLI, which // sends no merge notification. When a run that ran such a command ends, read the // thread's open links fresh, so settlement does not wait for the next sweep and the // cached summary. Other runs add no host reads. case "turn-item.updated": if ( event.payload.type === "command_execution" && - PULL_REQUEST_CLOSE_COMMAND.test(event.payload.input) + isChangeRequestCloseCommand(event.payload.input) ) { closeCommandThreads.add(event.threadId); } diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 30152d305382..ae3eed8794bb 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -13,7 +13,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -36,22 +36,12 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; -/** - * Checks take minutes, so a faster pass mostly spends the host's rate limit, - * which every machine on the same account shares. - */ +/** Checks take minutes; faster sweeps waste the account's shared host quota. */ const SWEEP_MINUTES = 2; const READ_FAILURE_LIMIT = 8; -/** - * Without a host fingerprint, a pull request with nothing in flight is read again only when its - * sync snapshot moves, or after this long, for news the snapshot cannot show, such as a bot - * editing its review. - */ +/** Without fingerprints, reread quiet PRs on snapshot changes or this interval to catch edits. */ const QUIET_REREAD_MS = 10 * 60_000; -/** - * With a host fingerprint, how long until the activity is read again anyway: the fingerprint - * does not see edits to comments inside review threads. - */ +/** Reread despite unchanged fingerprints; they miss review-thread comment edits. */ const FINGERPRINT_REREAD_MS = 30 * 60_000; const isProviderError = Schema.is(PullRequestProviderError); @@ -188,7 +178,7 @@ export class PullRequestWatchReactor extends Context.Service< } >()("t3/orchestration-v2/PullRequestWatchReactor") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const engine = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -467,8 +457,7 @@ export const make = Effect.gen(function* () { threadTails.delete(group.key); } const reference = referenceOf(group); - // The reads are recorded against this fingerprint, so a cached answer older than it must not - // stand in for them. Clients are not told: this only drops this pull request's cached reads. + // Invalidate cached reads before using the newer fingerprint; clients receive no notification. if (fingerprint !== null) yield* pullRequests.invalidate({ reference }); const read = yield* Effect.exit( Effect.all( @@ -586,8 +575,7 @@ export const make = Effect.gen(function* () { ending.push([target, reason]); continue; } - // Grouped per project too: each project reads through its own checkout, so one that cannot - // read the pull request must not end another project's watches. + // Group by project because checkout access differs; one failure must not end another project's watches. const key = `${target.thread.projectId} ${threadPullRequestKeyOf(target.link)}`; byPullRequest.set(key, [...(byPullRequest.get(key) ?? []), target]); } diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts index df184a610822..24269dfeaff2 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts @@ -93,7 +93,6 @@ it("names a lost subagent by the id the agent resumes it with", () => { description: "sleep 150", parentTaskId: "agent-1", }); - // The agent waits on the subagent, which waits on its shell. assert.deepEqual(ownShell, { kind: "subagent", label: "sleep 150 (subagent agent-1)", @@ -115,6 +114,13 @@ it("names a lost subagent by the id the agent resumes it with", () => { }).label; assert.lengthOf(longId, 160); assert.isFalse(isAwaitedWork(cancelledRosterTaskWork({ kind: "command", taskId: "shell-2" }))); + assert.isTrue( + isAwaitedWork(cancelledRosterTaskWork({ kind: "command", taskId: "tests", awaited: true })), + ); + assert.isFalse(isAwaitedWork(cancelledRosterTaskWork({ kind: "monitor", taskId: "watchdog" }))); + assert.isTrue( + isAwaitedWork(cancelledRosterTaskWork({ kind: "monitor", taskId: "ci", awaited: true })), + ); assert.equal( cancelledTurnItemWork({ id: "item-1", diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index d957daad6ff7..ba3830f65453 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -27,10 +27,7 @@ function labelWithId(name: unknown, fallback: string, suffix: string): string { return `${compactLabel(name, budget) ?? compactLabel(fallback, budget)}${suffix}`; } -/** - * `resumeId` is what the agent resumes a native subagent by, when that is not its - * item's native id (a Codex subagent resumes by its own provider thread's id). - */ +/** resumeId overrides the item id for native resume, such as Codex's child provider-thread id. */ export function cancelledTurnItemWork( item: OrchestrationV2TurnItem, resumeId?: string, @@ -51,7 +48,7 @@ export function cancelledTurnItemWork( } case "command_execution": return { - kind: "shell", + kind: item.monitor === true ? "monitor" : "shell", label: compactLabel(item.input) ?? compactLabel(item.title) ?? "background command", id: item.id, }; @@ -71,10 +68,7 @@ export function cancelledTurnItemWork( } } -/** - * The provider thread waiting on an item's work. A native subagent can run on - * its own child provider thread (Codex), but the turn that started it awaits it. - */ +/** Returns the awaiting provider thread; native subagents can run on children while their launching turn awaits them. */ export function awaitingProviderThreadId( item: OrchestrationV2TurnItem, runs: ReadonlyArray>, @@ -118,14 +112,13 @@ export function cancelledRosterTaskWork(task: OrchestrationV2PendingBackgroundTa description === undefined ? task.taskId : `${description} (id ${task.taskId})`, ) ?? "background task", id: task.taskId, + ...(task.awaited === true ? { awaited: true as const } : {}), }; } const MAX_NOTE_ENTRIES = 10; -/** - * Bounded (entries and label length) so it cannot crowd out the turn's context. - */ +/** Bounds entries and labels to protect the context budget. */ export function restartCancelledBackgroundWorkNote(work: ReadonlyArray): string { const omitted = work.length - MAX_NOTE_ENTRIES; return [ @@ -145,8 +138,7 @@ export function isRestartNoteSource( providerTurns: ReadonlyArray>, ): boolean { return ( - // A failed or interrupted turn is never continued. Recovery cancels a - // waiting run it cannot checkpoint, so a cancelled one still qualifies. + // Failed and interrupted turns never continue; recovery-cancelled waiting runs still qualify. (source.status === "completed" || source.status === "waiting" || source.status === "cancelled") && @@ -158,11 +150,12 @@ export function isRestartNoteSource( } /** - * A shell does not wake it: most are dev servers that nobody waits on, so their - * note waits for the next user message. + * Matches the Working-section hold: a shell or monitor wakes it only when the agent said it + * waits on it. Most are dev servers and their watchdogs, so their note waits for the next + * user message. */ -export function isAwaitedWork(work: Pick): boolean { - return work.kind !== "shell"; +export function isAwaitedWork(work: Pick): boolean { + return work.awaited === true || (work.kind !== "shell" && work.kind !== "monitor"); } export function runOutcome( @@ -174,21 +167,14 @@ export function runOutcome( : run.status; } -/** - * A run that never reached its provider, such as a provider wake the restart - * cut while it was starting: it lost no work and has no turn to continue. A - * prepared restart continuation still counts, since it carries its source. - */ +/** Unstarted runs have no lost work to continue, except prepared continuations carrying a source run. */ export function neverStarted( run: Pick, ): boolean { return run.startedAt === null && run.restartContinuationOfRunId === undefined; } -/** - * A run the restart cancelled before it started, so it supersedes nothing. A - * run the user stopped before it started still does. - */ +/** Restart-cancelled unstarted runs supersede nothing; user-stopped unstarted runs do. */ export function cancelledBeforeStart( run: Pick, ): boolean { @@ -204,11 +190,7 @@ export function isRestartContinuationSource( : source.status === "cancelled"; } -/** - * A continuation cut before its provider turn completed may not have delivered - * its own note: adapters can announce a running turn before accepting the - * prompt. Carry the note forward until a completed turn proves delivery. - */ +/** Running does not prove prompt acceptance; carry continuation notes until a completed turn proves delivery. */ export function restartContinuationNote( source: OrchestrationV2Run, runs: ReadonlyArray, @@ -254,12 +236,7 @@ export function isRestartNoteContinuation( ); } -/** - * A later completed turn on the same provider thread proves delivery, so the - * pending set is derived rather than cleared. Compactions and resumed turns - * carry no note, and a rolled-back run left native history, so none of them - * counts as delivery. - */ +/** Derive delivery from later completed turns on the same provider thread, excluding compactions, resumes, and rolled-back runs. */ export function pendingRestartCancelledBackgroundWork(input: { readonly runs: ReadonlyArray; readonly providerTurns: ReadonlyArray; @@ -304,8 +281,7 @@ export function pendingRestartCancelledBackgroundWork(input: { (candidate.restartContinuationOfRunId === undefined || isRestartNoteContinuation(candidate, input.runs, input.providerTurns, input.attempts)), ); - // A steer restarts this run on a new attempt: an earlier attempt that already - // reached the provider delivered the note, so the replacement must not repeat it. + // Earlier attempts may already have delivered the note; a steer's replacement attempt must not repeat it. const alreadyDelivered = input.attempts.some( (attempt) => attempt.runId === input.run.id && diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 2483534ed75d..061fc54a7f11 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -191,12 +191,12 @@ it("does not continue settled root runs with restart-cancelled background work", }); it.effect.each(["completed", "waiting", "cancelled"] as const)( - "wakes a %s run whose monitor the restart cancelled, with the note as its prompt", + "wakes a %s run whose awaited monitor the restart cancelled, with the note as its prompt", (status) => Effect.gen(function* () { const base = makeProjection(); const work = [ - { kind: "monitor" as const, label: "Watch CI run 42" }, + { kind: "monitor" as const, label: "Watch CI run 42", awaited: true as const }, { kind: "shell" as const, label: "vp run dev" }, ]; const projection = { @@ -368,7 +368,6 @@ it.effect.each(["running", "waiting"])( { ...base.providerTurns[0]!, status: status === "waiting" ? "completed" : "running" }, ], turnItems: [ - // Codex keeps a native subagent on its own child provider thread. item("item:native", { type: "subagent", origin: "provider_native", @@ -408,8 +407,6 @@ it.effect.each(["running", "waiting"])( const work = (committed?.events ?? []).flatMap((event) => event.type === "run.background-work-cancelled" ? [event.payload] : [], ); - // The subagent is reported on the run that awaited it. A running run's - // shell died with its turn. assert.deepEqual(work, [ { runId, @@ -737,7 +734,6 @@ it.effect("wakes a run whose subagent's own shell the restart cut, past a wake i providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }], }) as unknown as OrchestrationV2ThreadProjection; assert.equal(restartContinuationRun(withShell("agent:1"), now)?.id, runId); - // A shell the agent started itself still does not wake it. assert.isUndefined(restartContinuationRun(withShell(), now)); const texts = yield* continuationTexts({ @@ -758,7 +754,6 @@ it.effect("wakes a run whose subagent's own shell the restart cut, past a wake i } as unknown as OrchestrationV2ThreadProjection); assert.lengthOf(texts, 1); assert.include(texts[0], "- subagent: sleep 150 (subagent agent:1)"); - // A run the user stopped before it started still takes precedence. const stopped = yield* continuationTexts({ ...base, runs: [ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 1343fed42c58..c81faa6cf47c 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -81,10 +81,7 @@ function restartContinuationBlocked( ); } -/** - * App-owned delegations are not counted: their own threads continue and report - * back. - */ +/** App-owned delegations continue in their own threads and report back. */ function hasOpenAwaitedWork( projection: Pick, providerThread: ProjectionRuntimeRecoveryState["providerThreads"][number], @@ -110,10 +107,7 @@ function hasOpenAwaitedWork( ); } -/** - * A settled run needs a live session, so stale records from an older crash - * never wake it. - */ +/** Settled runs require a live session so stale crash records cannot wake them. */ export function restartContinuationRun( projection: Pick< ProjectionRuntimeRecoveryState, @@ -163,8 +157,7 @@ export function restartContinuationRun( const session = projection.providerSessions.find( (candidate) => candidate.id === providerThread.providerSessionId, ); - // Most adapters keep a live session "ready" through its turns, so only a - // stopped or failed session rules out a live turn. + // Adapters remain ready during turns; only stopped or failed sessions rule out liveness. if ( session === undefined || session.providerInstanceId !== run.providerInstanceId || @@ -303,11 +296,7 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta restartContinuationOfRunId: input.sourceRunId, }) .pipe( - // The project turned this instance off since the restart. No turn - // resumes, and the success path below still runs so a delegated - // child waiting on this run is recovered immediately instead of - // after EffectWorker exhausts its retries on a refusal that will - // not change. + // Disabled instances cannot resume; still run success cleanup so delegated children recover without futile retries. Effect.catchTags({ ThreadManagementProviderInstanceDisabledError: (error) => Effect.logInfo("orchestration-v2.restart-continuation.instance-disabled", { @@ -319,8 +308,7 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta }), ); }, - // A delegated child this declined to continue still owes its parent a - // result. Once a continuation run exists this is a no-op; that run settles it. + // Declined child continuations still owe parent results; existing continuation runs settle them instead. (effect, input) => effect.pipe( Effect.andThen( diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index e7937f8a1fc9..03bbbf0506b5 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -50,6 +50,11 @@ import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; const driver = ProviderDriverKind.make("codex"); +const rootAppThread = (id: ThreadId) => + ({ + id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, + }) as OrchestrationV2AppThread; const layerTurnItemPositionsTest = Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({ allocate: () => Effect.succeed(1), @@ -775,7 +780,7 @@ it.effect("rechecks run ownership immediately before calling the provider", () = yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-start-guard"), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId, session, run, @@ -830,7 +835,7 @@ it.effect("passes the thread's MCP app context to the provider under a safe key" yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-app-context"), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId: ProviderSessionId.make("session:run-execution-app-context"), session, run: { @@ -934,7 +939,7 @@ it.effect("fails the run when its ownership check cannot be read before calling const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-start-guard-read"), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId: ProviderSessionId.make("session:run-execution-start-guard-read"), session: { events: Stream.never, @@ -1029,7 +1034,7 @@ it.effect( const attemptId = RunAttemptId.make(`attempt:compact-routing:${index}`); yield* runExecution.startRootRun({ commandId: CommandId.make(`command:compact-routing:${index}`), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId: ProviderSessionId.make(`session:compact-routing:${index}`), session: { events: Stream.never, @@ -1145,7 +1150,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-baseline-failure"), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId, session: { events: Stream.never, @@ -1283,7 +1288,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-settings-failure"), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId, session: { events: Stream.never, @@ -1399,6 +1404,7 @@ it.effect("ingests new member threads of an owned child after the root turn term const subagentNodeId = NodeId.make("node:run-execution-late-child:subagent"); const childMessageIngested = yield* Deferred.make(); const order = yield* Ref.make>([]); + const origins = yield* Ref.make>([]); const layerTest = RunExecutionService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -1424,6 +1430,11 @@ it.effect("ingests new member threads of an owned child after the root turn term Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: (input) => Effect.gen(function* () { + const eventType = input.event.type; + yield* Ref.update(origins, (current) => [ + ...current, + [eventType, input.analyticsContext?.origin] as const, + ]); if ( input.event.type === "app_thread.created" && input.event.appThread.id === memberThreadId @@ -1559,7 +1570,7 @@ it.effect("ingests new member threads of an owned child after the root turn term const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-late-child"), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId, session: { events: Stream.fromIterable(events), @@ -1612,6 +1623,19 @@ it.effect("ingests new member threads of an owned child after the root turn term ); assert.isTrue(Option.isSome(observed), "child message was not ingested after root terminal"); assert.deepEqual(yield* Ref.get(order), ["root-finalized", "member-thread", "child-message"]); + // The run's message started the root; the parent agent started the child's turn. + const recorded = yield* Ref.get(origins); + assert.deepEqual(recorded[0], [ + "app_thread.created", + { createdBy: "user", creationSource: "web", scheduled: false, subagent: false }, + ]); + assert.deepEqual( + recorded.find(([type]) => type === "provider_turn.updated"), + [ + "provider_turn.updated", + { createdBy: "agent", creationSource: "provider", scheduled: false, subagent: true }, + ], + ); }), ); @@ -1704,7 +1728,7 @@ it.effect("ends a nested native subagent and its thread when the root run is int const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ commandId: CommandId.make(`command:${key}`), - appThread: { id: threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(threadId), providerSessionId: ProviderSessionId.make(`session:${key}`), session: { events: Stream.fromIterable(events), @@ -1826,6 +1850,17 @@ it.effect.each([null, "run:nested-sdk"] as const)( }), ); +it.effect("keeps ingesting a failed root's background item when its work continues", () => + Effect.gen(function* () { + const observed = yield* runBackgroundItemScenario("bg-failed-continues", (ids) => [ + backgroundTurnItemEvent(ids, "command_execution", "running", 1), + { ...rootTerminalEvent(ids, "failed"), childTurnsContinue: true }, + backgroundTurnItemEvent(ids, "command_execution", "completed", 2), + ]); + assert.deepEqual(observed, ["turn_item:running", "root-finalized", "turn_item:completed"]); + }), +); + it.effect("does not pin ingestion on background items when the root turn is interrupted", () => Effect.gen(function* () { const observed = yield* runBackgroundItemScenario("bg-interrupted", (ids) => [ @@ -2031,7 +2066,7 @@ it.effect( const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ commandId: CommandId.make("command:subagent-interrupt-cascade"), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(ids.threadId), providerSessionId: ProviderSessionId.make("session:subagent-interrupt-cascade"), session: { events: Stream.empty, @@ -2816,7 +2851,7 @@ function captureRootRunTermination(input: { const runExecution = yield* RunExecutionService.RunExecutionServiceV2; const start = runExecution.startRootRun({ commandId: CommandId.make(`command:${input.key}`), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(ids.threadId), providerSessionId: ProviderSessionId.make(`session:${input.key}`), session: { events: Stream.empty, @@ -3312,7 +3347,7 @@ function runBackgroundItemScenario( const runExecution = yield* RunExecutionService.RunExecutionServiceV2; yield* runExecution.startRootRun({ commandId: CommandId.make(`command:${key}`), - appThread: { id: ids.threadId } as OrchestrationV2AppThread, + appThread: rootAppThread(ids.threadId), providerSessionId: ProviderSessionId.make(`session:${key}`), session: { events: Stream.empty, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 11ab94cefa69..9ec61cc85b99 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -55,10 +55,7 @@ import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; export interface ProviderEventRoutingState { readonly ownedThreadIds: ReadonlySet; - // Set once this run's root turn ended. A child thread created after that - // belongs to the run that is live then, so this one no longer adopts it, - // except a workflow member enrolled below: its coordinator keeps this run - // open, so the member is this run's to follow. + // After root settlement, new children belong to the live run unless their coordinator still holds this run open. readonly rootTurnEnded: boolean; readonly workflowMemberParents: ReadonlyMap; readonly ownedProviderThreadIds: ReadonlySet; @@ -97,6 +94,14 @@ function isSettledSubagentStatus(status: OrchestrationV2Subagent["status"]): boo return !isOrchestrationV2WorkActive(status); } +/** A provider-native subagent's turns arrive on its parent's run, but the parent agent started them. */ +const NATIVE_SUBAGENT_TURN_ORIGIN: ProviderEventIngestor.ProviderTurnOrigin = { + createdBy: "agent", + creationSource: "provider", + scheduled: false, + subagent: true, +}; + const backgroundCapableTurnItemTypes: ReadonlySet = new Set([ "command_execution", "dynamic_tool", @@ -111,10 +116,7 @@ function isSettledRunEligibleForInheritedBackground(status: OrchestrationV2Run[" return status === "interrupted" || status === "failed" || status === "cancelled"; } -/** - * Provider sessions are runtime containers and can host multiple provider threads, so the durable - * provider-thread lineage is the discriminator. - */ +/** Use durable provider-thread lineage; sessions can host multiple threads. */ export function selectInheritedBackgroundTurnItems(input: { readonly threadId: ThreadId; readonly currentProviderThreadId: ProviderThreadId; @@ -173,10 +175,7 @@ function childTurnsContinue(terminal: ProviderTerminalEvent): boolean { return terminal.status === "failed" && terminal.childTurnsContinue === true; } -/** - * A running subagent stays with the run that launched it, which keeps ingesting until it ends; - * taking it over too would store its events twice. - */ +/** Launching runs still ingest running subagents; taking them over would duplicate events. */ export function canRouteRelatedSubagent(status: OrchestrationV2Subagent["status"]): boolean { return status === "completed"; } @@ -448,10 +447,7 @@ export function routeProviderEvent( } const belongs = ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId); const coordinator = event.subagent; - // A provider-native subagent's child thread follows the live run it is - // attributed to. A resume reattributes it to the resuming run, which - // never saw the child thread created when the subagent was not - // completed at its start (e.g. cancelled by a server restart). + // Resumes reattribute child threads to the live run, which may not have observed their creation. const adopted = ownsRun(coordinator.runId) && !state.rootTurnEnded && @@ -705,9 +701,7 @@ export const layer: Layer.Layer< : []; const persistedStatus = input.terminal.status === "completed" ? "waiting" : input.terminal.status; - // Completion cohorts are advanced by Orchestrator while a provider - // turn is in flight. Do not replay the run snapshot captured at start - // over a newer acknowledgement, successor, or Stop barrier. + // Omit delegatedCompletion; the startup snapshot must not overwrite newer acknowledgements, successors, or Stop barriers. const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = input.run; const finalizedRun: OrchestrationV2Run = { @@ -732,9 +726,7 @@ export const layer: Layer.Layer< const checkpointCaptureCommandId = CommandId.make( `command:effect:checkpoint.capture:${input.run.id}`, ); - // Stopped runs capture too: their checkpoint is the rollback point for - // the next message. The capture is enqueued with these terminal events, - // ahead of any later run's start on this thread's effect lane. + // Stopped runs capture rollback points for the next message; enqueue before later starts on this effect lane. const finalization = { effects: input.terminal.status === "completed" || @@ -883,6 +875,12 @@ export const layer: Layer.Layer< return RunExecutionServiceV2.of({ startRootRun: Effect.fn("RunExecutionService.startRootRun")(function* (input) { + const rootTurnOrigin: ProviderEventIngestor.ProviderTurnOrigin = { + createdBy: input.message.createdBy, + creationSource: input.message.creationSource, + scheduled: input.message.scheduledTaskId !== undefined, + subagent: input.appThread.lineage.relationshipToParent === "subagent", + }; // Startup failure and stream shutdown can report the same attempt. const refreshAfterTurn = yield* Effect.cached( finalizationObserver.refreshAfterTurn(input.appThread.projectId).pipe( @@ -1375,6 +1373,11 @@ export const layer: Layer.Layer< modelSelection: input.modelSelection, runtimeMode: input.runtimePolicy.runtimeMode, interactionMode: input.runtimePolicy.interactionMode, + origin: + event.type === "provider_turn.updated" && + event.providerTurn.providerThreadId !== input.providerThread.id + ? NATIVE_SUBAGENT_TURN_ORIGIN + : rootTurnOrigin, }, providerSessionId: input.providerSessionId, providerInstanceId: input.run.providerInstanceId, diff --git a/apps/server/src/orchestration-v2/RunMetrics.ts b/apps/server/src/orchestration-v2/RunMetrics.ts index 17b90d254418..b8b403c9b985 100644 --- a/apps/server/src/orchestration-v2/RunMetrics.ts +++ b/apps/server/src/orchestration-v2/RunMetrics.ts @@ -36,17 +36,10 @@ export interface RunOutcome { } /** - * Records each run once, when a committed event first shows it finished. A finished run's row is - * updated again later (its checkpoint, a settlement), so recently counted runs are remembered. - * - * A run carries `delegatedTaskId` only when a follow-up is associated with a task, so a subagent - * thread's first run is recognized by its thread's lineage instead. A failed lookup only costs the - * label: the events still commit and publish. - * - * `prepare` reads lineage, so the EventSink runs it inside the write transaction, on the - * connection that transaction already holds. `record` runs after the commit, under the publish - * lane, and must stay free of I/O: a read there waits for the writer connection while holding the - * lane, and the next writer holds that connection while it waits for the lane. + * Finished runs can change again; deduplicate outcomes and use lineage for first subagent runs. + * A failed lineage lookup must not block commit or publication. + * `prepare` must read inside the write transaction; `record` must do no I/O under the publish lane, + * or it can deadlock with a writer holding the connection while waiting for that lane. */ export const makeRunOutcomeRecorder = ( isSubagentThread: (threadId: ThreadId) => Effect.Effect, diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.ts index e58b7211ea31..6bd421058c4c 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.ts @@ -86,8 +86,7 @@ export const layer: Layer.Layer< requestId: input.requestId, }); } - // Dispatch validates the request while it is pending, then persists the - // resolved projection before this effect is executed. + // Dispatch persists resolution before this effect runs; pending requests are not executable. if (request.status !== "resolved") { return yield* new RuntimeRequestResponseExecutionError({ reason: "request-not-ready", diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index fcfaf8298f5e..229400dd8ce9 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -17,9 +17,11 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { + activeShellRowsForSubscriber, bufferShellLiveStream, composeShellStreamWithEnrichment, dedupeShellEnrichment, + omitIdleSubagentShells, shellStreamItemFromEnrichmentRefresh, shellStreamItemsFromInitialSnapshot, shouldPublishShellApplicationEvent, @@ -139,6 +141,69 @@ function shellFixture(overrides: Partial): Orchestra return { id: "thread-a", archivedAt: null, ...overrides } as OrchestrationV2ThreadShell; } +describe("omitIdleSubagentShells", () => { + const row = ( + id: string, + parent: string | null, + overrides: Partial = {}, + ) => + shellFixture({ + id: ThreadId.make(id), + lineage: { + parentThreadId: parent === null ? null : ThreadId.make(parent), + relationshipToParent: parent === null ? null : "subagent", + rootThreadId: ThreadId.make("root"), + }, + status: "completed", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + ...overrides, + }); + + it("drops idle leaf children but keeps live ones and the middle of nested chains", () => { + const threads = [ + row("root", null), + row("idle", "root"), + row("running", "root", { activityRunStatus: "running" }), + row("queued", "root", { status: "queued" }), + row("asking", "root", { + pendingRuntimeRequest: { + id: "request" as never, + kind: "user_input", + createdAt: DateTime.makeUnsafe(0), + }, + }), + row("background", "root", { + pendingBackgroundTasks: [{ kind: "subagent" } as never], + }), + // Idle, but a woken grandchild walks through it to reach the root's badge. + row("middle", "root"), + row("grandchild", "middle"), + ]; + expect(omitIdleSubagentShells(threads).map((thread) => thread.id)).toEqual([ + "root", + "running", + "queued", + "asking", + "background", + "middle", + ]); + }); + + it("still filters the transcript events of omitted children for a subscriber that opts in", () => { + const threads = [row("root", null), row("idle", "root")]; + const omitted = activeShellRowsForSubscriber(threads, true); + expect(omitted.threads.map((thread) => thread.id)).toEqual(["root"]); + expect( + shouldPublishShellApplicationEvent( + { event: { threadId: "idle", type: "turn-item.updated" } } as ApplicationStoredEvent, + omitted.subagentThreadIds, + ), + ).toBe(false); + }); +}); + describe("shellStreamItemsFromInitialSnapshot", () => { it("keeps thread rows out of the metadata-only enrichment frame", () => { const snapshot = { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 7a1367aa6792..6952dfd2a9cb 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -9,6 +9,7 @@ import type { OrchestrationV2StoredEvent, } from "@t3tools/contracts"; import { + isLiveSubagentShell, OrchestrationProjectShell as ProjectShellSchema, OrchestrationV2ThreadShell as ThreadShellSchema, } from "@t3tools/contracts"; @@ -64,6 +65,49 @@ export function buildActiveShellSnapshot(input: { }; } +/** + * 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 { + const parentIds = new Set(); + for (const thread of threads) { + if (thread.lineage.relationshipToParent === "subagent" && thread.lineage.parentThreadId) { + parentIds.add(thread.lineage.parentThreadId); + } + } + return threads.filter( + (thread) => + thread.lineage.relationshipToParent !== "subagent" || + isLiveSubagentShell(thread) || + parentIds.has(thread.id), + ); +} + +/** + * The active rows a shell subscriber gets, and the subagent ids whose transcript events its stream + * drops. The ids come from every row, so an omitted child that wakes keeps that filter. + */ +export function activeShellRowsForSubscriber( + threads: ReadonlyArray, + omitIdleSubagents: boolean, +): { + readonly threads: ReadonlyArray; + readonly subagentThreadIds: Set; +} { + return { + threads: omitIdleSubagents ? omitIdleSubagentShells(threads) : threads, + subagentThreadIds: new Set( + threads + .filter((thread) => thread.lineage.relationshipToParent === "subagent") + .map((thread) => thread.id), + ), + }; +} + /** * One transaction covers the reads so they agree; the threads are decoded after it commits, * because the server shares one SQLite connection and decoding a large shell takes longer than @@ -160,10 +204,7 @@ export function coalesceShellApplicationEvents( ); } -/** - * Prevents a newer marked enrichment from landing before the unmarked authoritative initial - * snapshot. - */ +/** Emit the authoritative initial snapshot before marked enrichment. */ export function composeShellStreamWithEnrichment(input: { readonly initial: Stream.Stream; readonly tail: Stream.Stream; @@ -328,11 +369,7 @@ const sameThreadShell = Schema.toEquivalence(ThreadShellSchema); const UNCHANGED_THREAD_SHELL_RESEND_MS = 5_000; -/** - * A skipped delta leaves the client cursor behind, and a resume replays the events after that - * cursor. So an unchanged shell is still sent once its last send is - * `UNCHANGED_THREAD_SHELL_RESEND_MS` old. - */ +/** Resend unchanged shells after UNCHANGED_THREAD_SHELL_RESEND_MS so skipped deltas cannot leave resume cursors behind. */ export function skipUnchangedThreadShells( stream: Stream.Stream, ): Stream.Stream { diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 208934624d9e..1a5ee5087270 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -825,9 +825,7 @@ it.effect("starts a steer that missed the turn on the saved next-turn selection" ), ); -// A draft that never reaches a paragraph boundary is held back while it -// streams. Codex only flushes it at turn end, after the final answer is written, -// so the draft must already own its place from before the steer. +// Codex flushes held drafts after the final answer; reserve their pre-steer position before turn end. it.effect("keeps a held commentary draft before the steer when turn end flushes it", () => Effect.scoped( Effect.gen(function* () { @@ -873,8 +871,6 @@ it.effect("keeps a held commentary draft before the steer when turn end flushes Effect.forkScoped, ); - // The cut-off draft starts first; the commentary after it is written, - // so the draft has been seen by the time the steer arrives. yield* Queue.offer(events, assistantItem("draft", "Let me look at the", true)); const commentaryWritten = yield* watch( (event) => event.type === "turn-item.updated" && event.payload.id === "commentary", diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index 3d261ed85e2f..6793421a6433 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -24,7 +24,11 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as EventStore from "./EventStore.ts"; -import { encodeThreadHistoryCursor, selectHistoryPageFromCursor } from "./threadHistoryPaging.ts"; +import { + encodeThreadHistoryCursor, + selectHistoryPageFromCursor, + THREAD_HISTORY_PAGE_POLICY, +} from "./threadHistoryPaging.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; const layerTest = Layer.mergeAll(EventStore.layer, ProjectionStore.layer, ProjectStore.layer).pipe( @@ -722,7 +726,7 @@ describe("V2 history materialization", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("pages complete turns without duplicating or skipping messages", () => + it.effect("pages long turns without duplicating or skipping messages", () => Effect.gen(function* () { const projection = yield* setup; const items = Array.from({ length: 45 }, (_, turn) => [ @@ -763,7 +767,12 @@ describe("V2 history materialization", () => { snapshotSequence: snapshot.snapshotSequence, }), }); - assert.equal(page.items[0]?.item.type, "user_message"); + // A turn larger than the byte budget is split, but only a lone oversized item may exceed it. + assert.isTrue( + page.items.length === 1 || + Buffer.byteLength(JSON.stringify(page.items), "utf8") <= + THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + ); collected.unshift(...page.items.map((row) => String(row.sourceItemId))); cursor = page.nextCursor; } diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index 2a663ae6d08f..a59d56766672 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -264,8 +264,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( }); }); }); - // Each scan visits every message, so a cache smaller than the thread is cycled through and - // never hits. Real threads reach a few thousand messages of a few hundred characters. + // Scans visit every message; a cache smaller than the thread churns without hits. const textCache = yield* Cache.make({ capacity: 8192, timeToLive: "1 minute", diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index f3cf10851ae9..3350d31fb708 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -145,7 +145,6 @@ it.effect("a persisted fork leaves PR watching with its parent until explicitly assert.deepEqual(fork.pendingBackgroundTasks, []); assert.deepEqual(yield* orchestrator.getThreadShell(sourceThreadId), parentBefore); - // A delayed watcher update cannot reinstate the parent's subscription on the fork. const stale = yield* orchestrator .dispatch({ type: "thread.pull-request-watch.sync", @@ -168,7 +167,6 @@ it.effect("a persisted fork leaves PR watching with its parent until explicitly assert.equal(stale._tag, "OrchestratorDispatchError"); assert.deepEqual((yield* orchestrator.getThreadProjection(targetThreadId)).runs, []); - // Advance virtual time only to distinguish the independent watch's start time. yield* TestClock.adjust("1 second"); yield* orchestrator.dispatch({ type: "thread.pull-request.watch", @@ -194,7 +192,6 @@ it.effect("a persisted fork leaves PR watching with its parent until explicitly const stoppedFork = yield* orchestrator.getThreadShell(targetThreadId); const [forkLink] = fork.pullRequests ?? []; assert.isDefined(forkLink); - // The fork remembers its own watch, never the parent's, so watching again resumes from it. assert.deepEqual(stoppedFork?.pullRequests, [ { ...forkLink, endedWatch: watchingFork.pullRequests[0].watch }, ]); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index c31a5e4611a2..a660de9a4e6a 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -31,9 +31,7 @@ export class ThreadForkPlanError extends Schema.TaggedError }, ) {} -/** - * `waiting` is provider-finished with checkpoint capture still pending. - */ +/** waiting means provider-finished with checkpoint capture pending. */ export function isForkableSourceRunStatus(status: OrchestrationV2Run["status"]): boolean { return ( status === "completed" || @@ -110,7 +108,11 @@ export const layer: Layer.Layer = Layer.succeed( settledAt: null, snoozedUntil: null, snoozedAt: null, + // A guest link belongs to the thread it was made for. + guestShare: null, lastVisitedAt: null, + // A fork is a new thread; it does not inherit how often the source was opened. + opens: null, deletedAt: null, }; const transfer: OrchestrationV2ContextTransfer = { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 0391412fe5f9..0d8aa495a201 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -671,7 +671,6 @@ it.effect("keeps native maintenance commands out of steering and restart message attachments: [], dispatchMode: { type: scenario.mode, targetRunId: targetRun.id }, }); - // A steer sent while a maintenance turn runs waits as a queued follow-up. if (scenario.name.startsWith("steer-")) { yield* dispatch; const queued = (yield* threads.getThreadProjection(launched.threadId)).runs[1]; @@ -1597,6 +1596,73 @@ it.effect("does not let a failing same-command caller strand a concurrent durabl }), ); +it.effect("reports one concurrent same-command launch as new and the rest as resumed", () => + Effect.gen(function* () { + // Both launches read no receipt before either one claims the thread. + const arrived = yield* Ref.make(0); + const bothRead = yield* Deferred.make(); + const harness = makeHarness({ + managedFolders: Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + folderForThread: () => + Ref.updateAndGet(arrived, (count) => count + 1).pipe( + Effect.tap((count) => + count === 2 ? Deferred.succeed(bothRead, undefined) : Effect.void, + ), + Effect.andThen(Deferred.await(bothRead)), + Effect.as(Option.none()), + ), + }), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const input = launchInput({ + command: "command:launch:concurrent-resumed", + thread: "thread:launch:concurrent-resumed", + }); + const results = yield* Effect.all([launches.launch(input), launches.launch(input)], { + concurrency: "unbounded", + }); + assert.deepEqual(results.map((result) => result.resumed).sort(), [false, true]); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect( + "does not report a retry as resumed when the earlier attempt only claimed the thread", + () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = launchInput({ + command: "command:launch:claimed-only", + thread: "thread:launch:claimed-only", + message: "Counted once", + }); + yield* threads.dispatch({ + type: "thread.create", + commandId: input.commandId, + threadId: input.threadId, + projectId: input.projectId, + title: input.title, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: null, + worktreePath: null, + createdBy: input.createdBy, + creationSource: input.creationSource, + }); + const retried = yield* launches.launch(input); + assert.isFalse(retried.resumed); + assert.isTrue((yield* launches.launch(input)).resumed); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("schedules an accepted preparing message exactly once across concurrent retries", () => Effect.gen(function* () { const setupEntered = yield* Deferred.make(); @@ -2001,7 +2067,6 @@ it.effect("stops an in-flight branch rename when tracked setup is cancelled", () yield* Deferred.await(setupEntered); yield* Deferred.await(branchNameStarted); assert.isTrue(yield* tracker.cancel(launched.threadId)); - // Let a rename that survived the cancel finish, then check it never ran. yield* Deferred.succeed(allowBranchName, undefined); yield* Effect.raceFirst( Deferred.await(branchNameInterrupted), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index f1c3d432a10b..ff032f757bbe 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -114,6 +114,7 @@ export interface ThreadLaunchPreparationInput { export interface ThreadLaunchResult { readonly threadId: ThreadId; readonly projection: OrchestrationV2ThreadProjection; + /** The launch's last write (the initial message, else the thread claim) was accepted before this call. */ readonly resumed: boolean; } @@ -166,10 +167,7 @@ export class ThreadLaunchService extends Context.Service< ThreadLaunchResult, ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError >; - /** - * `launch` accepts such a launch and reports the failure inside the - * thread; a caller that needs it before a thread exists checks first. - */ + /** Check before creation for early errors; launch otherwise accepts and reports base failures inside the thread. */ readonly checkWorktreeBase: ( input: Pick, ) => Effect.Effect; @@ -272,8 +270,7 @@ const make = Effect.gen(function* () { (yield* git.resolveWorktreeBaseRemote({ cwd, refName: baseRef })) !== null ) return undefined; - // `git worktree add` also starts from a remote-tracking branch of that - // name; any ref carrying it is reason enough to let Git decide. + // git worktree add accepts remote-tracking branches; let Git decide when any matching ref exists. if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return undefined; return (yield* git.hasCommit({ cwd, refName: "HEAD" })) ? project : undefined; }).pipe(Effect.orElseSucceed(() => undefined)); @@ -308,8 +305,7 @@ const make = Effect.gen(function* () { let setupTerminalId: string | null = null; let workspaceRecorded = false; let runReleased = false; - // The background branch rename records the worktree again when it lands, - // so cleanup stops it before forgetting a removed worktree. + // Stop background renames before forgetting worktrees; completed renames record them again. let branchRename: Fiber.Fiber | null = null; if (input.workspaceStrategy.type === "worktree") { yield* setupTracker.begin({ @@ -455,8 +451,7 @@ const make = Effect.gen(function* () { yield* setupTracker.stageStatus(threadId, "checkout", "done"); } - // A reused worktree is already recorded, and rewriting it could undo - // the first attempt's branch rename. + // Do not rewrite reused worktrees; that could undo the original branch rename. if (reused === undefined) { yield* threads .dispatch({ @@ -508,8 +503,7 @@ const make = Effect.gen(function* () { }), ), ); - // Updating the parent first makes later subagents inherit the new name. - // Re-read after each pass for children of subagents updated in that pass. + // 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(); @@ -678,9 +672,7 @@ const make = Effect.gen(function* () { ) { if (branchRename !== null) yield* Fiber.interrupt(branchRename); const removedPath = createdWorktreePath; - // The thread forgets the worktree only once it is gone; a failed - // removal leaves the directory for the user to clean up rather than - // reusing a checkout that may be half written. + // Forget worktrees only after removal succeeds; failed removals must not reuse partially written checkouts. yield* git .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true }) .pipe( @@ -843,10 +835,7 @@ const make = Effect.gen(function* () { instanceId: input.modelSelection.instanceId, }); return yield* Effect.gen(function* () { - // A retried launch has no client-supplied id to replay against, so - // recover the thread id its accepted create was recorded under before - // allocating another one; a fresh id would only collide with the - // recorded receipt. + // Recover ids from accepted launch receipts before allocating; retries have no client id and new ids collide with receipts. const reusableLaunchReceipt = input.threadId === undefined && Option.isSome(launchReceipt) && @@ -974,6 +963,8 @@ const make = Effect.gen(function* () { creationSource: input.creationSource, }) .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); + // A concurrent retry can read no receipt and still replay the dispatch. + messageWasAlreadyAccepted ||= dispatched.replayed === true; const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); @@ -1034,7 +1025,12 @@ const make = Effect.gen(function* () { return { threadId, projection, - resumed: Option.isSome(launchReceipt) || messageWasAlreadyAccepted, + // An attempt that failed after claiming the thread recorded nothing, so + // only a repeat of the final write counts as resumed. + resumed: + input.initialMessage === undefined + ? Option.isSome(launchReceipt) || claimed.replayed === true + : messageWasAlreadyAccepted, }; }); }, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 8d1f613180a3..0314008c6369 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -3,6 +3,10 @@ import { CommandId, MessageId, type OrchestrationV2Run, + type OrchestrationV2ProviderThread, + ProviderThreadId, + ProviderDriverKind, + ProviderSessionId, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadProjection, ProjectId, @@ -12,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -23,7 +28,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; const layerReceiptsTest = CommandReceiptStore.layer.pipe( Layer.provide(SqlitePersistence.layerMemory), @@ -297,6 +304,103 @@ it.effect("waitForThread returns the cut run once its continuation is declined", }), ); +it.effect.each(["live", "no-session", "unsupported-runtime"] as const)( + "marks background work awaited on the thread's active provider thread with state %s", + (state) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:background-wait"); + const providerThreadId = ProviderThreadId.make("provider-thread:background-wait"); + const providerSessionId = ProviderSessionId.make("provider-session:background-wait"); + const driver = ProviderDriverKind.make("claudeAgent"); + const now = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [{ taskId: "tests", kind: "command" }], + contextUsage: null, + nativeMetadata: null, + createdAt: now, + updatedAt: now, + }; + const calls: Array<{ providerThreadId: ProviderThreadId; awaited: boolean }> = []; + const runtime = { + driver, + providerSessionId, + ...(state === "unsupported-runtime" + ? {} + : { + awaitBackgroundTasks: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly taskIds: ReadonlyArray; + readonly awaited: boolean; + }) => + Effect.sync(() => { + calls.push({ + providerThreadId: input.providerThread.id, + awaited: input.awaited, + }); + return { matched: input.taskIds.filter((taskId) => taskId === "tests") }; + }), + }), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { + id: threadId, + deletedAt: null, + activeProviderThreadId: providerThreadId, + }, + providerThreads: [providerThread], + } as unknown as OrchestrationV2ThreadProjection), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: () => + Effect.succeed(state === "no-session" ? Option.none() : Option.some(runtime)), + }), + ), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const result = yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + return yield* service + .awaitBackgroundTasks({ threadId, taskIds: ["tests", "finished"], awaited: true }) + .pipe(Effect.result); + }).pipe(Effect.provide(Layer.merge(layerTest, ThreadCommandExecutor.layer))); + + if (state === "live") { + expect(result).toMatchObject({ + success: { matched: ["tests"], unmatched: ["finished"] }, + }); + expect(calls).toEqual([{ providerThreadId, awaited: true }]); + } else { + expect(result).toMatchObject({ + failure: { + _tag: "ThreadManagementBackgroundWaitError", + reason: state === "no-session" ? "unavailable" : "unsupported", + }, + }); + expect(calls).toEqual([]); + } + }), +); + it.effect.each([ { status: "completed" as const, settles: true }, { status: "failed" as const, settles: false }, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index fba2df1de5c2..17ad6c6bbc6f 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -6,6 +6,7 @@ import type { ProjectionRecordField, ProjectionRecordFilter, ProjectionRecords, + ShellSnapshotOptions, } from "./ProjectionStore.ts"; import { type ChatAttachment, @@ -87,7 +88,7 @@ function existingThreadIdsForCommand( case "thread.create": return []; // Read-state commands only rewrite the thread payload's visited/unread - // watermark; they never touch messages, so they do not need the imported + // watermark and open stats; they never touch messages, so they do not need the imported // v1 transcript hydrated first. Visits fire on every thread-activity bump // while a thread is open, so keeping them off the import path matters. case "thread.visit": @@ -295,6 +296,26 @@ export class ThreadManagementProviderInstanceDisabledError extends Schema.Tagged } } +export class ThreadManagementBackgroundWaitError extends Schema.TaggedError()( + "ThreadManagementBackgroundWaitError", + { + threadId: ThreadId, + reason: Schema.Literals(["unsupported", "unavailable", "failed"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "unsupported": + return "This provider cannot mark background work as awaited."; + case "unavailable": + return "This thread has no live provider session."; + case "failed": + return "Failed to update the awaited background work."; + } + } +} + export const ThreadManagementError = Schema.Union([ ThreadManagementThreadNotFoundError, ThreadManagementRunNotFoundError, @@ -363,9 +384,9 @@ export interface ThreadManagementServiceShape { readonly location?: "active" | "archive"; }) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; - readonly readShellSnapshot: (options?: { - readonly location?: "active" | "archive"; - }) => Effect.Effect< + readonly readShellSnapshot: ( + options?: Pick, + ) => Effect.Effect< Effect.Effect, Orchestrator.OrchestratorV2Error >; @@ -386,22 +407,13 @@ export interface ThreadManagementServiceShape { readonly projectId: ProjectId; readonly instanceId: ProviderInstanceId; }) => Effect.Effect; - /** - * A caller resolving a target ahead of a dispatch under this same command id - * can use it to skip project-enablement filtering: the dispatch will only - * replay the recorded result, never start anything new, so the filter would - * just refuse a retry the project's settings no longer allow fresh. - */ + /** Receipt replays start no work; skip enablement checks with the same command id so settings cannot reject accepted retries. */ readonly hasReceipt: (commandId: CommandId) => Effect.Effect; readonly waitForThread: ( input: ThreadManagementWaitInput, ) => Effect.Effect; - /** - * Settling stops the provider session, so an agent that settles its own - * thread uses this to wait for its turn to end. A run that ends any other - * way leaves the thread active, as does anything `thread.settle` refuses - * then, such as a queued message. - */ + /** Waits for the agent's run to end before settling its thread and stopping the session. + * Other endings or settle refusals leave the thread active. */ readonly settleAfterRun: (input: { readonly projectId: ProjectId; readonly threadId: ThreadId; @@ -421,6 +433,14 @@ export interface ThreadManagementServiceShape { readonly stopWorkflow: ( input: OrchestrationV2StopWorkflowInput, ) => Effect.Effect; + readonly awaitBackgroundTasks: (input: { + readonly threadId: ThreadId; + readonly taskIds: ReadonlyArray; + readonly awaited: boolean; + }) => Effect.Effect< + { readonly matched: ReadonlyArray; readonly unmatched: ReadonlyArray }, + ThreadManagementBackgroundWaitError + >; readonly stopDelegatedTasks: (input: { readonly threadId: ThreadId; readonly commandId: CommandId; @@ -1024,9 +1044,7 @@ const make = Effect.gen(function* () { if (records.thread.deletedAt !== null) { return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); } - // A limited caller (an agent over MCP) may not stop work on a thread - // running above its own modes. Checked under the thread lock, where the - // thread's user cannot raise the modes between this read and the stop. + // Check mode limits under the lock so users cannot raise modes between validation and Stop. const limit = yield* DispatchModeLimit; const exceeded = limit === undefined ? undefined : exceededDispatchModeLimit(limit, records.thread); @@ -1097,6 +1115,55 @@ const make = Effect.gen(function* () { (effect, input) => effect.pipe((effect) => threadDispatch.withLock(input.threadId, effect)), ); + const awaitBackgroundTasks: ThreadManagementServiceShape["awaitBackgroundTasks"] = Effect.fn( + "ThreadManagementService.awaitBackgroundTasks", + )(function* (input) { + const unavailable = (cause?: unknown) => + new ThreadManagementBackgroundWaitError({ + threadId: input.threadId, + reason: "unavailable", + ...(cause === undefined ? {} : { cause }), + }); + const records = yield* orchestrator + .getThreadRecords(input.threadId, ["providerThreads"]) + .pipe(Effect.mapError(unavailable)); + const providerThread = records.providerThreads.find( + (candidate) => + candidate.id === records.thread.activeProviderThreadId && + candidate.appThreadId === input.threadId, + ); + if (records.thread.deletedAt !== null || providerThread?.providerSessionId == null) { + return yield* unavailable(); + } + const runtime = yield* sessions + .get(providerThread.providerSessionId) + .pipe(Effect.mapError(unavailable)); + if (Option.isNone(runtime)) { + return yield* unavailable(); + } + if (runtime.value.awaitBackgroundTasks === undefined) { + return yield* new ThreadManagementBackgroundWaitError({ + threadId: input.threadId, + reason: "unsupported", + }); + } + const { matched } = yield* runtime.value + .awaitBackgroundTasks({ providerThread, taskIds: input.taskIds, awaited: input.awaited }) + .pipe( + Effect.timeout("15 seconds"), + Effect.mapError( + (cause) => + new ThreadManagementBackgroundWaitError({ + threadId: input.threadId, + reason: "failed", + cause, + }), + ), + ); + const matchedIds = new Set(matched); + return { matched, unmatched: input.taskIds.filter((taskId) => !matchedIds.has(taskId)) }; + }); + const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = Effect.fn( "ThreadManagementService.stopDelegatedTasks", )(function* (input) { @@ -1189,6 +1256,7 @@ const make = Effect.gen(function* () { interruptThread, stopDelegatedTasks, stopWorkflow, + awaitBackgroundTasks, getThreadEventSequence: orchestrator.getThreadEventSequence, recoverDelegatedTask: orchestrator.recoverDelegatedTask, delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 2a884d19d3fb..16707e2e357d 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -7,6 +7,8 @@ import { ChatAttachmentId, CommandId, EventId, + GuestMessageId, + MessageId, RuntimeRequestId, ThreadId, TurnItemId, @@ -18,7 +20,11 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { + createAttachmentId, + createPendingAttachmentId, + resolveAttachmentPath, +} from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { @@ -32,18 +38,21 @@ const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { prefix: "t3-question-intake-", }).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest())); +const layerFailingDispatchHandler = + (captured: OrchestrationV2ServerCommand[]) => (command: OrchestrationV2ServerCommand) => { + captured.push(command); + return Effect.fail( + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Stop after intake", + }), + ); + }; + const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) => Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: (command) => { - captured.push(command); - return Effect.fail( - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Stop after intake", - }), - ); - }, + dispatch: layerFailingDispatchHandler(captured), }); const NOW = DateTime.makeUnsafe("2026-09-13T00:00:00.000Z"); @@ -359,3 +368,57 @@ it.effect("a retried response re-claims the preserved pending uploads", () => ).toHaveLength(2); }).pipe(Effect.provide(layerIntakeTest)), ); + +it.effect("sends a guest message with the guest's files, never ones the client names", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const guestFile = { + type: "image" as const, + id: ChatAttachmentId.make(createAttachmentId("thread-guest")!), + name: "screen.png", + mimeType: "image/png", + sizeBytes: 3, + }; + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `${guestFile.id}.png`), + new Uint8Array([1, 2, 3]), + ); + const guestMessageId = GuestMessageId.make("guest-message:files"); + const captured: OrchestrationV2ServerCommand[] = []; + const layerGuestShare = Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: layerFailingDispatchHandler(captured), + // Only the share is read. + getThreadSnapshotWindow: () => + Effect.succeed({ + projection: { + thread: { + guestShare: { + messages: [{ id: guestMessageId, status: "pending", attachments: [guestFile] }], + }, + }, + }, + } as unknown as Effect.Success< + ReturnType< + ThreadManagementService.ThreadManagementServiceShape["getThreadSnapshotWindow"] + > + >), + }); + yield* dispatchCommand({ + type: "message.dispatch", + commandId: CommandId.make("send-guest-message"), + threadId: ThreadId.make("thread-guest"), + messageId: MessageId.make("message-guest"), + guestMessageId, + text: "", + attachments: [{ ...guestFile, id: ChatAttachmentId.make("thread-guest-owner-file") }], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }).pipe(Effect.provide(layerGuestShare), Effect.result); + const command = captured[0]; + if (command?.type !== "message.dispatch") throw new Error("Expected a message dispatch."); + expect(command.attachments).toHaveLength(1); + // The guest's file, which is already the thread's. + expect(command.attachments).toEqual([guestFile]); + }).pipe(Effect.provide(layerIntakeTest)), +); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index fa8f068849da..308020d6959b 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -11,8 +11,7 @@ import * as AttachmentClaims from "./AttachmentClaims.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; -// These dispatcher failures occur in receipt validation or planning, before -// commitCommand. Generic dispatch errors can follow a commit and remain uncertain. +// These failures precede commitCommand; generic dispatch errors can follow commits and remain ambiguous. function dispatchWasNotAccepted( error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError, ) { @@ -53,9 +52,39 @@ const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims") ); }); +/** + * A guest message goes out with the files the guest attached, never ones a client names. They + * are already the thread's attachments. A message no longer waiting gets none; the orchestrator + * then refuses or replays the send. + */ +const withGuestAttachments = Effect.fn("ThreadMessageIntake.withGuestAttachments")(function* ( + command: Extract, +) { + if (command.guestMessageId === undefined) return command; + const threads = yield* ThreadManagement.ThreadManagementService; + // Only the thread record is needed; the window keeps the read small. + const { projection } = yield* threads + .getThreadSnapshotWindow(command.threadId, { rowLimit: 1 }) + .pipe( + Effect.mapError( + (cause) => + new AttachmentClaims.AttachmentClaimError({ + message: "Failed to read the guest message.", + cause, + }), + ), + ); + const message = projection.thread.guestShare?.messages.find( + (candidate) => candidate.id === command.guestMessageId && candidate.status === "pending", + ); + return { ...command, attachments: message?.attachments ?? [] }; +}); + export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(function* ( - command: OrchestrationV2Command, + incoming: OrchestrationV2Command, ) { + const command = + incoming.type === "message.dispatch" ? yield* withGuestAttachments(incoming) : incoming; const threads = yield* ThreadManagement.ThreadManagementService; if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; @@ -94,12 +123,8 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( }) .pipe( Effect.tap((result) => { - // A replayed receipt reports the first attempt's answer, so this - // attempt's copies go unreferenced and are released. The resolved - // turn item proves the respond was applied; questionAnswer is only - // recorded when the accepted command carried attachments, so its - // absence means the accepted answer referenced no copies. With no - // resolved item at all the outcome is ambiguous and everything stays. + // Replayed receipts refer to the first answer; release this attempt's unused copies only with a resolved item. + // Missing questionAnswer means no accepted attachments; missing resolution leaves delivery ambiguous. const answeredItems = result.storedEvents.flatMap(({ event }) => event.type === "turn-item.updated" && event.payload.type === "user_input_request" && @@ -225,8 +250,7 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi if (error._tag === "ThreadManagementProviderInstanceDisabledError") { return AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths); } - // Project/receipt reads precede message dispatch. The create-thread error - // also wraps post-message projection reads, so its tag alone is not proof. + // Project/receipt reads precede dispatch; create-thread errors can wrap post-message reads and prove nothing alone. const notAccepted = error.operation === "resolve-project" || error.operation === "read-receipt" || diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index b4c323412267..d0b2deacc11e 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -85,8 +85,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { it.effect("does not sweep branch pull requests while no client is connected", () => Effect.scoped( Effect.gen(function* () { - // Reproduces #23702: an idle server with one active branch thread kept - // calling GitHub for that branch every minute with no client connected. + // Polling active branches without a connected client wastes GitHub requests. const thread = { ...threadShell("idle-branch-thread"), branch: "feature/idle" }; const project: OrchestrationProjectShell = { id: thread.projectId, @@ -137,7 +136,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/project.git", }, - provider: "github" as const, + provider: SourceControlProviderKind.make("github"), owner: "acme", name: "project", }), @@ -261,7 +260,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/project.git", }, - provider: "github" as const, + provider: SourceControlProviderKind.make("github"), owner: "acme", name: "project", }), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 2c3f03b0d348..d7855abf7a91 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -1,7 +1,3 @@ -import { - canonicalRepositoryKey, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; import { CommandId, type OrchestrationProjectShell, @@ -29,6 +25,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as Orchestrator from "./Orchestrator.ts"; class ThreadPullRequestServiceV2 extends Context.Service< @@ -59,8 +56,8 @@ function pullRequestMatchesProject( return ( pullRequest.repositoryKey !== null && project.repositoryIdentity != null && - canonicalRepositoryKey(pullRequest.repositoryKey) === - canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + sourceControlDefinitions.canonicalRepositoryKey(pullRequest.repositoryKey) === + sourceControlDefinitions.canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) ); } @@ -71,14 +68,13 @@ const resolveProjectForPullRequestDiscovery = Effect.fn( repositoryIdentities: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], options?: { readonly refresh?: boolean }, ) { - // Identities stay cached for 15 minutes. A finished turn may have added the - // remote its pull request lives on, so post-turn discovery refreshes. + // Post-turn discovery refreshes 15-minute identity caches because agents may add remotes. const repositoryIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { refresh: options?.refresh ?? false, }); return { project: { ...project, repositoryIdentity }, - repository: sourceControlRepositorySelector(repositoryIdentity), + repository: sourceControlDefinitions.repositorySelector(repositoryIdentity), }; }); @@ -131,8 +127,7 @@ export const make = Effect.gen(function* () { }) .pipe(NodeSqliteClient.readOnly) : Effect.gen(function* () { - // Read the sequence first. The thread is then at least this new, so a - // sync guarded by the sequence is rejected rather than missing a change. + // Read sequence first so guarded sync rejects intervening changes. const snapshotSequence = yield* orchestrator.getThreadEventSequence(threadId); const thread = yield* orchestrator.getThreadShell(threadId); return { snapshotSequence, threads: thread === null ? [] : [thread] }; @@ -300,9 +295,7 @@ export const make = Effect.gen(function* () { updates, ({ thread, branchPullRequest, replacement }) => Effect.gen(function* () { - // Discovery can perform network I/O. Re-read the project at - // the transaction boundary so a deleted project or changed - // workspace root cannot apply a result from the old checkout. + // Discovery yields to network I/O; recheck the project at commit to reject deleted projects or changed checkouts. const currentProject = yield* projectStore.getShell(project.id); if (!projectWorkspaceMatchesSnapshot(currentProject, project.workspaceRoot)) { return failBackfill([thread]); @@ -347,8 +340,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach the GitHub provider together and share one - // GraphQL document, instead of one `gh pr list` per branch. + // Batch concurrent branch lookups into one GitHub GraphQL document. { concurrency: 32, discard: true }, ); }); diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index f12355a49e55..13042a32846b 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -11,6 +11,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -59,7 +60,12 @@ const createProject = (projectId: ProjectId) => const thread = ( threadId: ThreadId, projectId: ProjectId, - overrides: { readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc } = {}, + overrides: { + readonly archivedAt?: DateTime.Utc; + readonly deletedAt?: DateTime.Utc; + readonly subagentOf?: ThreadId; + readonly title?: string; + } = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`created:${threadId}`), type: "thread.created", @@ -71,7 +77,7 @@ const thread = ( creationSource: "web", id: threadId, projectId, - title: threadId, + title: overrides.title ?? threadId, providerInstanceId, modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, runtimeMode: "full-access", @@ -79,7 +85,13 @@ const thread = ( branch: null, worktreePath: null, activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + lineage: overrides.subagentOf + ? { + parentThreadId: overrides.subagentOf, + relationshipToParent: "subagent", + rootThreadId: overrides.subagentOf, + } + : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, forkedFrom: null, createdAt: at(0), updatedAt: at(0), @@ -96,7 +108,11 @@ const message = ( id: string, role: "user" | "assistant" | "system", text: string, - options: { readonly minute?: number; readonly streaming?: boolean } = {}, + options: { + readonly minute?: number; + readonly streaming?: boolean; + readonly createdBy?: "user" | "agent"; + } = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`message:${id}`), type: "message.updated", @@ -104,7 +120,7 @@ const message = ( providerInstanceId, occurredAt: at(options.minute ?? 1), payload: { - createdBy: role === "user" ? "user" : "agent", + createdBy: options.createdBy ?? (role === "user" ? "user" : "agent"), creationSource: role === "user" ? "web" : "provider", id: MessageId.make(id), threadId, @@ -120,6 +136,21 @@ const message = ( }); it.layer(layerTest)("ThreadSearch", (it) => { + it.effect("reads the newest hits without sorting all of them", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + for (const match of ['"needle"', null]) { + const plan = yield* sql<{ + readonly detail: string; + }>`EXPLAIN QUERY PLAN ${ThreadSearch.messageHitsQuery(sql, { match, substrings: "[]", projectId: null, excludedThreadIds: "[]" })}`; + assert.isFalse( + plan.some((row) => row.detail.includes("TEMP B-TREE")), + plan.map((row) => row.detail).join("\n"), + ); + } + }), + ); + it.effect("returns one finished user or assistant match per active thread", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -168,18 +199,626 @@ it.layer(layerTest)("ThreadSearch", (it) => { const result = yield* search.search({ query: "NEEDLE", limit: 20 }); assert.deepEqual( - result.matches.map((match) => [match.threadId, match.source, match.snippet]), + result.matches + .map((match) => [match.threadId, match.source, match.snippet, match.messageId]) + .toSorted(([left], [right]) => String(left).localeCompare(String(right))), [ - [both, "user", "newer needle question"], - [assistantOnly, "assistant", "needle in an answer"], + [assistantOnly, "assistant", "needle in an answer", "assistant-only"], + [both, "user", "newer needle question", "both-user-new"], ], ); assert.lengthOf((yield* search.search({ query: "needle", limit: 1 })).matches, 1); assert.deepEqual((yield* search.search({ query: "ne%le" })).matches, []); }), ); + + it.effect("indexes a message when it finishes streaming and follows later edits", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:search-sync"); + const threadId = ThreadId.make("thread:sync"); + yield* createProject(project); + yield* projections.apply(thread(threadId, project)); + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + + yield* projections.apply( + message(threadId, "sync", "assistant", "streaming draft", { streaming: true }), + ); + assert.deepEqual(yield* threadIds("draft"), []); + + yield* projections.apply(message(threadId, "sync", "assistant", "finished answer")); + assert.deepEqual(yield* threadIds("finished"), [threadId]); + + yield* projections.apply(message(threadId, "sync", "assistant", "edited reply")); + assert.deepEqual(yield* threadIds("finished"), []); + assert.deepEqual(yield* threadIds("edited"), [threadId]); + }), + ); + + it.effect("matches inside identifiers and skips subagent threads and short tokens", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:search-tokens"); + const parent = ThreadId.make("thread:tokens-parent"); + const subagent = ThreadId.make("thread:tokens-subagent"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(parent, project), + message(parent, "parent", "user", "Rename ThreadSearchService before shipping"), + thread(subagent, project, { subagentOf: parent }), + message(subagent, "subagent", "assistant", "ThreadSearchService renamed"), + ], + projections.apply, + { discard: true }, + ); + + const result = yield* search.search({ query: "search servi" }); + assert.deepEqual( + result.matches.map((match) => [match.threadId, match.snippet]), + [[parent, "Rename ThreadSearchService before shipping"]], + ); + // "ab" alone cannot use the index; with a longer token it narrows the hits. + assert.deepEqual((yield* search.search({ query: "ab" })).matches, []); + assert.lengthOf((yield* search.search({ query: "renam be" })).matches, 1); + assert.lengthOf((yield* search.search({ query: "renam ab" })).matches, 0); + }), + ); + + it.effect("indexes neither subagent messages nor rewrites that keep the text", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const project = ProjectId.make("project:search-index-writes"); + const parent = ThreadId.make("thread:index-parent"); + const subagent = ThreadId.make("thread:index-subagent"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(parent, project), + message(parent, "index-parent", "assistant", "parent answer"), + thread(subagent, project, { subagentOf: parent }), + message(subagent, "index-subagent", "assistant", "subagent answer"), + ], + projections.apply, + { discard: true }, + ); + const indexedThreads = sql<{ readonly threadId: string }>` + SELECT messages.thread_id AS "threadId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = search.rowid + WHERE messages.thread_id IN (${parent}, ${subagent}) + `.pipe(Effect.map((rows) => rows.map((row) => row.threadId))); + assert.deepEqual(yield* indexedThreads, [parent]); + + // total_changes counts trigger writes, so 1 means the index was untouched. + const changes = sql<{ readonly n: number }>`SELECT total_changes() AS n`.pipe( + Effect.map(([row]) => row?.n ?? 0), + ); + const before = yield* changes; + yield* sql` + UPDATE orchestration_v2_projection_messages + SET payload_json = json_set(payload_json, '$.updatedAt', '2026-09-27T01:00:00.000Z') + WHERE message_id = 'index-parent' + `; + assert.strictEqual((yield* changes) - before, 1); + assert.deepEqual(yield* indexedThreads, [parent]); + }), + ); }); +it.layer(layerTest)("ThreadSearch filters before it ranks", (it) => { + it.effect("orders message matches by when they were written, not by row order", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:written"); + yield* createProject(project); + const recent = ThreadId.make("thread:written-recent"); + const imported = ThreadId.make("thread:written-imported"); + // An imported history lands after newer messages in row order. + yield* Effect.forEach( + [ + thread(recent, project), + message(recent, "written-recent", "user", "kestrel deploy notes", { minute: 30 }), + thread(imported, project), + message(imported, "written-imported", "user", "kestrel deploy notes", { minute: 1 }), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "kestrel" }); + assert.deepEqual( + matches.map((match) => match.threadId), + [recent, imported], + ); + }), + ); + + it.effect("ranks a thread about the word above a passing mention", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:about"); + yield* createProject(project); + const about = ThreadId.make("thread:about-osprey"); + const mention = ThreadId.make("thread:mention-osprey"); + yield* Effect.forEach( + [ + thread(about, project), + ...Array.from({ length: 12 }, (_, index) => + message(about, `about-osprey-${index}`, "assistant", `osprey step ${index}`, { + minute: 2, + }), + ), + thread(mention, project), + message(mention, "mention-osprey", "user", "also check osprey", { minute: 5 }), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "osprey" }); + assert.deepEqual( + matches.map((match) => [match.threadId, match.hits]), + [ + [about, 12], + [mention, 1], + ], + ); + }), + ); + + it.effect("counts a thread's hits at its best match only", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:best-hits"); + yield* createProject(project); + const clarks = ThreadId.make("thread:clarks"); + const larks = ThreadId.make("thread:lark-many"); + yield* Effect.forEach( + [ + thread(clarks, project), + message(clarks, "clarks-lark", "user", "Lark once", { minute: 3 }), + ...Array.from({ length: 31 }, (_, index) => + message(clarks, `clarks-${index}`, "assistant", "Clark the plan", { minute: 3 }), + ), + thread(larks, project), + ...Array.from({ length: 8 }, (_, index) => + message(larks, `lark-many-${index}`, "assistant", "Lark again", { minute: 3 }), + ), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "lark" }); + assert.deepEqual( + matches.map((match) => [match.threadId, match.hits]), + [ + [larks, 8], + [clarks, 1], + ], + ); + }), + ); + + it.effect("finds a visible match behind hundreds of hidden or narrower hits", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:crowded"); + const parent = ThreadId.make("thread:crowded-parent"); + const subagent = ThreadId.make("thread:crowded-subagent"); + const older = ThreadId.make("thread:crowded-older"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(parent, project), + thread(subagent, project, { subagentOf: parent }), + thread(older, project), + ...Array.from({ length: 450 }, (_, index) => + message(subagent, `subagent-${index}`, "assistant", "needle needle"), + ), + ...Array.from({ length: 450 }, (_, index) => + message(older, `older-${index}`, "assistant", "pnpm 11 pnpm"), + ), + message(parent, "parent-needle", "user", "a needle in a long haystack of other words"), + // Agent-written notices in user messages are not indexed, so they never read as "You". + message(older, "older-notice", "user", "needle update from a watched pull request", { + createdBy: "agent", + }), + message(parent, "parent-pnpm", "user", "moved the repo to pnpm 12 today"), + ], + projections.apply, + { discard: true }, + ); + + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + assert.deepEqual(yield* threadIds("needle"), [parent]); + assert.deepEqual(yield* threadIds("pnpm 12"), [parent]); + }), + ); + + it.effect("keeps a thread picked for the query when the limit cuts", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:plover"); + const busy = ThreadId.make("thread:plover-busy"); + const picked = ThreadId.make("thread:plover-picked"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(busy, project), + message(busy, "plover-1", "user", "plover notes", { minute: 3 }), + message(busy, "plover-2", "user", "more plover notes", { minute: 4 }), + thread(picked, project), + message(picked, "plover-old", "user", "one plover mention", { minute: 1 }), + ], + projections.apply, + { discard: true }, + ); + const threadIds = () => + search + .search({ query: "plover", limit: 1 }) + .pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + assert.deepEqual(yield* threadIds(), [busy]); + // A pick at time 0 would read as no pick. + yield* TestClock.adjust("1 day"); + const at = DateTime.toEpochMillis(yield* DateTime.now); + yield* sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.opens', json(${JSON.stringify({ + openedAt: at, + score: 1, + picks: [{ query: "plover", at }], + })})) + WHERE thread_id = ${picked} + `; + assert.deepEqual(yield* threadIds(), [picked]); + }), + ); + + it.effect("finds a curly apostrophe for a straight one and back", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:apostrophes"); + const curly = ThreadId.make("thread:apostrophe-curly"); + const straight = ThreadId.make("thread:apostrophe-straight"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(curly, project), + message(curly, "apostrophe-curly", "user", "the gannet\u2019s nest"), + thread(straight, project), + message(straight, "apostrophe-straight", "user", "a kittiwake's nest"), + ], + projections.apply, + { discard: true }, + ); + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + assert.deepEqual(yield* threadIds("gannet's"), [curly]); + assert.deepEqual(yield* threadIds("kittiwake\u2019s"), [straight]); + }), + ); + + it.effect("leaves out answers to an agent's question, which show in its card", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:answers"); + const asked = ThreadId.make("thread:answers"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(asked, project), + message(asked, "async-answer:request-1", "user", "use the albatross drive"), + ], + projections.apply, + { discard: true }, + ); + assert.deepEqual((yield* search.search({ query: "albatross" })).matches, []); + }), + ); + + it.effect("matches messages as they read, with links as their text", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:heron"); + const labeled = ThreadId.make("thread:heron-label"); + const addressOnly = ThreadId.make("thread:heron-address"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(labeled, project), + message(labeled, "heron-label", "assistant", "See [the heron plan](https://x.dev/a) now"), + thread(addressOnly, project), + message(addressOnly, "heron-address", "user", "Read [this](https://heron.dev/b)"), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "heron" }); + assert.deepEqual( + matches.map((match) => [match.threadId, match.snippet]), + [[labeled, "See the heron plan now"]], + ); + }), + ); + + it.effect("reads at most the newest 10,000 index hits, however many the filters drop", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:kestrel"); + const old = ThreadId.make("thread:kestrel-old"); + const busy = ThreadId.make("thread:kestrel-busy"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(old, project), + message(old, "kestrel-old", "user", "kestrel ci is red"), + thread(busy, project), + ], + projections.apply, + { discard: true }, + ); + const insertBusy = (count: number) => sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${count}) + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + SELECT 'kestrel-busy-' || i, ${busy}, 'assistant', 0, ${DateTime.formatIso(at(5))}, + ${DateTime.formatIso(at(5))}, json_object('text', 'kestrel packages') + FROM n + `; + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + yield* insertBusy(9_999); + assert.deepEqual(yield* threadIds("kestrel ci"), [old]); + yield* sql`DELETE FROM orchestration_v2_projection_messages WHERE thread_id = ${busy}`; + yield* insertBusy(10_000); + assert.deepEqual(yield* threadIds("kestrel ci"), []); + }), + ); +}); + +it.layer(layerTest)("ThreadSearch across scripts and without the index", (it) => { + it.effect( + "matches Korean, Japanese, and two-character Chinese words, and whole short words", + () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:scripts"); + const threadId = ThreadId.make("thread:scripts"); + const drafts = ThreadId.make("thread:drafts"); + const addresses = ThreadId.make("thread:addresses"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(threadId, project), + message(threadId, "korean", "user", "검색 기능을 고쳐 주세요"), + message(threadId, "japanese", "user", "がんばって検索します"), + message(threadId, "chinese", "user", "明天部署到生产环境"), + message(threadId, "fts", "assistant", "the FTS index is ready"), + thread(drafts, project), + message(drafts, "drafts", "assistant", "saved two drafts and shifts"), + message(threadId, "camel", "assistant", "Configure enableFTS today"), + message(threadId, "accented", "user", "Review Résumé indexing"), + thread(addresses, project), + message(addresses, "addresses", "user", "This addresses Résumé indexing"), + ], + projections.apply, + { discard: true }, + ); + const threadIds = (query: string) => + search + .search({ query }) + .pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + + // Short words must start a word, which camelCase and accented words do. + for (const query of ["검색 기능", "がんばって", "部署", "fts", "enable"]) { + assert.deepEqual(yield* threadIds(query), [threadId], query); + } + // "res" inside "addresses" does not hide the accented word after it. + assert.sameMembers(yield* threadIds("res"), [threadId, addresses]); + // A word beats the same letters inside a longer word, even in an older message. + const trust = ThreadId.make("thread:trust"); + const rust = ThreadId.make("thread:rust"); + yield* Effect.forEach( + [ + thread(rust, project), + message(rust, "rust", "assistant", "Rust rewrite notes"), + thread(trust, project), + message(trust, "trust", "user", "We trust the build"), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.search({ query: "rust" }); + assert.deepEqual( + matches.map((match) => [match.threadId, (match.quality ?? 1) <= 0.3]), + [ + [rust, false], + [trust, true], + ], + ); + // Without the index (an SQLite build without FTS5 trigrams) search scans instead. + yield* sql`DROP TABLE orchestration_v2_message_search`; + assert.deepEqual(yield* threadIds("検索"), [threadId]); + assert.deepEqual(yield* threadIds("fts"), [threadId]); + }), + ); +}); + +it.layer(layerTest)("ThreadSearch.searchThreads", (it) => { + it.effect("keeps room for a message-only thread behind many titled ones", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:petrel"); + yield* createProject(project); + const messageOnly = ThreadId.make("thread:petrel-message"); + yield* Effect.forEach( + [ + thread(messageOnly, project), + // The oldest message, so a cut taken before titles drop would lose it. + message(messageOnly, "petrel-old", "user", "a petrel note", { minute: 1 }), + ...Array.from({ length: 50 }, (_, index) => { + const id = ThreadId.make(`thread:petrel-${index}`); + return [ + thread(id, project, { title: `Petrel ${index}` }), + message(id, `petrel-${index}`, "user", "petrel again", { minute: 2 }), + ]; + }).flat(), + ], + projections.apply, + { discard: true }, + ); + const { matches } = yield* search.searchThreads({ query: "petrel", limit: 5 }); + assert.include( + matches.map((match) => match.threadId), + messageOnly, + ); + }), + ); + + it.effect("leaves titled threads out before the hit limit", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:hit-limit"); + const messageOnly = ThreadId.make("thread:grebe-message"); + const titled = ThreadId.make("thread:grebe-titled"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(messageOnly, project, { title: "Unrelated" }), + message(messageOnly, "grebe-old", "user", "a grebe note", { minute: 1 }), + thread(titled, project, { title: "Grebe" }), + ], + projections.apply, + { discard: true }, + ); + // Newer hits than one read holds, all in the titled thread. + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000) + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + SELECT 'grebe-titled-' || i, ${titled}, 'assistant', 0, ${DateTime.formatIso(at(5))}, + ${DateTime.formatIso(at(5))}, json_object('text', 'grebe again') + FROM n + `; + const { matches } = yield* search.searchThreads({ query: "grebe", limit: 5 }); + assert.include( + matches.map((match) => match.threadId), + messageOnly, + ); + }), + ); + + it.effect("ranks title matches like the palette, then message matches, within one project", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:titles"); + const otherProject = ProjectId.make("project:titles-other"); + yield* createProject(project); + yield* createProject(otherProject); + const wordStart = ThreadId.make("thread:word-start"); + const midWord = ThreadId.make("thread:mid-word"); + const messageOnly = ThreadId.make("thread:message-only"); + const elsewhere = ThreadId.make("thread:elsewhere"); + yield* Effect.forEach( + [ + thread(midWord, project, { title: "Fix oauth redirect" }), + thread(wordStart, project, { title: "Clean up auth tokens" }), + thread(messageOnly, project, { title: "Unrelated" }), + message(messageOnly, "message-only", "user", "the auth flow broke again"), + thread(elsewhere, otherProject, { title: "Auth everywhere" }), + ], + projections.apply, + { discard: true }, + ); + + // Stronger message hits in another project must not use up this project's limit. + yield* Effect.forEach( + Array.from({ length: 55 }, (_, index) => { + const busy = ThreadId.make(`thread:elsewhere-${index}`); + return [ + thread(busy, otherProject), + message(busy, `elsewhere-${index}`, "user", "auth auth auth"), + ]; + }).flat(), + projections.apply, + { discard: true }, + ); + const result = yield* search.searchThreads({ query: "auth", projectId: project, limit: 3 }); + assert.deepEqual( + result.matches.map((match) => [match.threadId, match.source]), + [ + [wordStart, "title"], + [midWord, "title"], + [messageOnly, "user"], + ], + ); + // With one slot, an exact title keeps it. + assert.deepEqual( + (yield* search.searchThreads({ query: "auth", projectId: project, limit: 1 })).matches.map( + (match) => match.threadId, + ), + [wordStart], + ); + // Exact matching finds nothing, so one typo is forgiven. + assert.deepEqual( + (yield* search.searchThreads({ query: "tokesn", projectId: project })).matches.map( + (match) => match.title, + ), + ["Clean up auth tokens"], + ); + }), + ); +}); + +it.effect("starts snippets a few words before the match so one line shows it", () => + Effect.sync(() => { + const snippet = (text: string, word: string) => + ThreadSearch.buildSearchSnippet(text, text.indexOf(word)); + const long = `${"lead ".repeat(60)}the NEEDLE sits here ${"tail ".repeat(60)}`; + const cut = snippet(long, "NEEDLE"); + assert.isAtMost(cut.length, 240); + assert.match(cut, /^…(lead ){2,4}the NEEDLE sits here/); + assert.isTrue(cut.endsWith("…")); + // Whitespace collapses only in the shown part, so the offset still lands. + assert.match(snippet(`${"word\n\n ".repeat(60)}un café`, "café"), /un café$/); + // A cut never splits an emoji's surrogate pair. + assert.notMatch( + snippet(`x${"🚀".repeat(200)}needle`, "needle"), + /[\ud800-\udbff](?![\udc00-\udfff])|(? Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 0862c5160dad..34f2a64e6c2a 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -1,18 +1,46 @@ import { IsoDateTime, + MessageId, OrchestrationThreadSearchSource, + OrchestrationV2AppThreadJson, + OrchestrationV2ThreadOpens, type OrchestrationSearchThreadsInput, type OrchestrationSearchThreadsResult, ProjectId, ThreadId, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlSchema from "effect/sql/SqlSchema"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { + messageSearchTokens, + foldSearchQuery, + rankMessageMatches, + rankSearchItems, + messageMatcher, + scoreThreadSearch, + type ThreadSearchSubject, +} from "@t3tools/shared/paletteSearch"; +import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests"; +import { indexedMessageCondition } from "./messageSearchIndex.ts"; + +// When the user last wrote in the thread aliased `threads`, as the clients +// rank recency; agent notices in the user role do not count. +const latestUserMessageAt = (sql: SqlClient.SqlClient) => sql`( + SELECT messages.updated_at + FROM orchestration_v2_projection_messages AS messages + WHERE messages.thread_id = threads.thread_id + AND messages.role = 'user' + AND COALESCE(json_extract(messages.payload_json, '$.createdBy'), 'user') = 'user' + ORDER BY messages.updated_at DESC, messages.message_id DESC + LIMIT 1 +)`; export class ThreadSearchError extends Schema.TaggedError()( "ThreadSearchError", @@ -26,142 +54,431 @@ export class ThreadSearchError extends Schema.TaggedError()( } } -const SearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int }); +// Token lists bind as JSON arrays, so any number of them is one parameter. +const TokenList = Schema.fromJsonString(Schema.Array(Schema.String)); +const SearchRequest = Schema.Struct({ + /** The FTS5 query, or null to scan messages instead. */ + match: Schema.NullOr(Schema.String), + /** Tokens every hit must contain, checked on the text. */ + substrings: TokenList, + projectId: Schema.NullOr(ProjectId), + /** Threads the caller already lists, left out before the hit limit. */ + excludedThreadIds: TokenList, +}); const SearchRow = Schema.Struct({ + messageRowid: Schema.Number, + messageId: MessageId, threadId: ThreadId, projectId: ProjectId, source: OrchestrationThreadSearchSource, - matchText: Schema.String, + text: Schema.String, messageCreatedAt: Schema.NullOr(IsoDateTime), }); -function escapeLikePattern(value: string): string { - return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_"); +const ThreadOpensRow = Schema.Struct({ + threadId: ThreadId, + opens: Schema.NullOr(Schema.String), + latestUserMessageAt: Schema.NullOr(IsoDateTime), +}); +const decodeThreadOpens = Schema.decodeUnknownOption( + Schema.fromJsonString(OrchestrationV2ThreadOpens), +); + +const SNIPPET_LENGTH = 240; +// The newest hits read per query. A common word stops here instead of reading +// every message, so time stays flat as history grows. +const MESSAGE_HIT_LIMIT = 2000; +// How many of the newest index hits the filters may pass over to fill that +// limit: a rare short word or hidden threads would otherwise read every hit. +const MESSAGE_CANDIDATE_LIMIT = 10_000; +// Without the index, how many of the newest messages a search scans. +const MESSAGE_SCAN_LIMIT = 20_000; + +/** + * The SQL inputs for a message search, or null when the query has no token + * long enough to search. Tokens of three or more characters go to the + * trigram index as quoted phrases; shorter ones, and every token when the + * index cannot serve the query, are checked as substrings of the hits. + */ +function buildMessageSearch( + query: string, + hasIndex: boolean, +): Omit | null { + const { long, short } = messageSearchTokens(query); + if (long.length === 0) return null; + // Two-character CJK words are too short for trigrams. + const indexed = hasIndex && long.every((token) => Array.from(token).length >= 3); + return { + match: indexed ? long.map((token) => `"${token.replaceAll('"', '""')}"`).join(" ") : null, + substrings: indexed ? short : [...long, ...short], + }; } -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +// A thread shows a link as its text, so in-thread find cannot reach a match +// in the address; messages are matched as they read. +const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\([^)\s]*\)/g; + +// Result rows show one line, so the match must sit near the start to be seen. +const SNIPPET_LEAD = 24; + +const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff; + +/** + * Up to 240 characters starting a few words before the match at `matchStart`, + * whitespace collapsed, with ellipses where the text was cut. The client finds + * the highlights again in the result. + */ +export function buildSearchSnippet(text: string, matchStart: number): string { + let start = Math.max(0, matchStart - SNIPPET_LEAD); + const wordStart = text.slice(start, matchStart).search(/\s/); + if (start > 0 && wordStart !== -1) start += wordStart + 1; + // Cut between characters, not inside an emoji's surrogate pair. + if (isLowSurrogate(text.charCodeAt(start))) start++; + // Collapsing whitespace shortens the text, so read more of it. + const rawEnd = Math.min(text.length, start + SNIPPET_LENGTH * 2); + // Emphasis and code marks do not show in a thread either. + const shown = text + .slice(start, rawEnd) + .replace(/\*\*|`+/g, "") + .replace(/\s+/g, " ") + .trim(); + let end = Math.min(shown.length, SNIPPET_LENGTH - 2); + if (isLowSurrogate(shown.charCodeAt(end))) end--; + const cut = end < shown.length || rawEnd < text.length; + return `${start > 0 ? "…" : ""}${shown.slice(0, end)}${cut ? "…" : ""}`; } -function buildSearchSnippet(text: string, query: string): string { - const normalizedText = text.replace(/\s+/g, " ").trim(); - if (normalizedText.length <= 240) { - return normalizedText; - } - const normalizedQuery = foldAsciiCase(query.replace(/\s+/g, " ").trim()); - const matchIndex = foldAsciiCase(normalizedText).indexOf(normalizedQuery); - const bodyLength = 236; - const idealStart = Math.max(0, matchIndex - 72); - const start = Math.min(idealStart, normalizedText.length - bodyLength); - const end = Math.min(normalizedText.length, start + bodyLength); - return `${start > 0 ? "…" : ""}${normalizedText.slice(start, end)}${ - end < normalizedText.length ? "…" : "" - }`; +// Hidden subagent threads must be filtered before the hit limit so visible matches aren't lost. +// Word starts and ranking follow the title rules in JS. +// Without FTS5 trigrams, or for two-character CJK words, scan the same eligible messages. +// The scan folds ASCII case only, so accent variants and non-ASCII case variants may not match. +export const messageHitsQuery = ( + sql: SqlClient.SqlClient, + { match, substrings, projectId, excludedThreadIds }: typeof SearchRequest.Encoded, +) => sql` + WITH eligible AS MATERIALIZED ( + SELECT threads.thread_id, threads.project_id + FROM orchestration_v2_projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND (${projectId} IS NULL OR threads.project_id = ${projectId}) + AND threads.thread_id NOT IN (SELECT value FROM json_each(${excludedThreadIds})) + AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') + IS NOT 'subagent' + ) + ${ + match === null + ? sql` + SELECT + messages.rowid AS "messageRowid", + messages.message_id AS "messageId", + messages.thread_id AS "threadId", + eligible.project_id AS "projectId", + messages.role AS source, + json_extract(messages.payload_json, '$.text') AS text, + messages.created_at AS "messageCreatedAt" + FROM orchestration_v2_projection_messages AS messages + INNER JOIN eligible ON eligible.thread_id = messages.thread_id + WHERE ${indexedMessageCondition(sql)} + -- A scan reads every message it passes, so it stops at the newest ones. + AND messages.rowid > ( + SELECT coalesce(max(rowid), 0) FROM orchestration_v2_projection_messages + ) - ${MESSAGE_SCAN_LIMIT} + ` + : sql` + SELECT + orchestration_v2_message_search.rowid AS "messageRowid", + messages.message_id AS "messageId", + messages.thread_id AS "threadId", + eligible.project_id AS "projectId", + messages.role AS source, + json_extract(messages.payload_json, '$.text') AS text, + messages.created_at AS "messageCreatedAt" + FROM orchestration_v2_message_search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = orchestration_v2_message_search.rowid + INNER JOIN eligible ON eligible.thread_id = messages.thread_id + WHERE orchestration_v2_message_search MATCH ${match} + AND orchestration_v2_message_search.rowid >= coalesce(( + SELECT rowid FROM orchestration_v2_message_search + WHERE orchestration_v2_message_search MATCH ${match} + ORDER BY rowid DESC + LIMIT 1 OFFSET ${MESSAGE_CANDIDATE_LIMIT - 1} + ), 0) + ` + } + -- Answers to an agent's question show inside its question card, where + -- neither find nor a jump can reach them. + AND messages.message_id NOT LIKE 'async-answer:%' + AND NOT EXISTS ( + SELECT 1 FROM json_each(${substrings}) AS token + WHERE instr(lower(json_extract(messages.payload_json, '$.text')), token.value) = 0 + ) + -- The index's own rowid, so FTS5 returns hits newest first and stops at + -- the limit; ordering by the messages table's rowid sorts every hit. + ORDER BY "messageRowid" DESC + LIMIT ${MESSAGE_HIT_LIMIT} + `; + +const TitleCandidateRow = Schema.Struct({ + thread: Schema.fromJsonString(OrchestrationV2AppThreadJson), + projectTitle: Schema.String, + latestUserMessageAt: Schema.NullOr(IsoDateTime), +}); + +export interface ThreadSearchHit { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly title: string; + readonly source: "title" | OrchestrationThreadSearchSource; + readonly snippet?: string; + readonly messageCreatedAt?: string | null; } export class ThreadSearch extends Context.Service< ThreadSearch, { + /** Message text matches, for clients that rank their own thread list. */ readonly search: ( input: OrchestrationSearchThreadsInput, ) => Effect.Effect; + /** + * Title, branch, pull request, and project matches ranked as the command + * palette ranks them, then message matches for the remaining threads. + */ + readonly searchThreads: ( + input: OrchestrationSearchThreadsInput & { readonly projectId?: ProjectId | undefined }, + ) => Effect.Effect<{ readonly matches: ReadonlyArray }, ThreadSearchError>; } >()("t3/orchestration-v2/ThreadSearch") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const searchRows = SqlSchema.findAll({ Request: SearchRequest, Result: SearchRow, - execute: ({ pattern, limit }) => sql` - WITH candidate AS ( - SELECT - threads.thread_id, - threads.project_id, - messages.role, - json_extract(messages.payload_json, '$.text') AS match_text, - messages.created_at AS message_created_at, - messages.message_id, - threads.updated_at AS thread_updated_at - FROM orchestration_v2_projection_messages AS messages - INNER JOIN orchestration_v2_projection_threads AS threads - ON threads.thread_id = messages.thread_id - INNER JOIN projection_projects AS projects - ON projects.project_id = threads.project_id - WHERE threads.deleted_at IS NULL - AND threads.archived_at IS NULL - AND projects.deleted_at IS NULL - AND messages.streaming = 0 - AND messages.role IN ('user', 'assistant') - AND json_extract(messages.payload_json, '$.text') LIKE ${pattern} ESCAPE '!' - ), - ranked AS ( - SELECT - thread_id, - project_id, - role AS source, - match_text, - message_created_at, - CASE role WHEN 'user' THEN 0 ELSE 1 END AS match_rank, - thread_updated_at, - ROW_NUMBER() OVER ( - PARTITION BY thread_id - ORDER BY - CASE role WHEN 'user' THEN 0 ELSE 1 END ASC, - message_created_at DESC, - message_id ASC - ) AS thread_match_rank - FROM candidate - ) + execute: (request) => messageHitsQuery(sql, request), + }); + + const threadOpens = SqlSchema.findAll({ + Request: Schema.Struct({ threadIds: TokenList }), + Result: ThreadOpensRow, + execute: ({ threadIds }) => sql` + SELECT + threads.thread_id AS "threadId", + json_extract(threads.payload_json, '$.opens') AS opens, + ${latestUserMessageAt(sql)} AS "latestUserMessageAt" + FROM orchestration_v2_projection_threads AS threads + WHERE threads.thread_id IN (SELECT value FROM json_each(${threadIds})) + `, + }); + + const titleCandidates = SqlSchema.findAll({ + Request: Schema.Struct({ projectId: Schema.NullOr(ProjectId) }), + Result: TitleCandidateRow, + execute: ({ projectId }) => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - source, - match_text AS "matchText", - message_created_at AS "messageCreatedAt" - FROM ranked - WHERE thread_match_rank = 1 - ORDER BY match_rank ASC, thread_updated_at DESC, thread_id ASC - LIMIT ${limit} + threads.payload_json AS thread, + projects.title AS "projectTitle", + ${latestUserMessageAt(sql)} AS "latestUserMessageAt" + FROM orchestration_v2_projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') + IS NOT 'subagent' + AND (${projectId} IS NULL OR threads.project_id = ${projectId}) `, }); - const search: ThreadSearch["Service"]["search"] = Effect.fn("ThreadSearch.search")( - function* (input) { - // The scan reads every message, so run it on the reader worker rather - // than the event loop. - const rows = yield* sql - .withTransaction( - searchRows({ - pattern: `%${escapeLikePattern(input.query)}%`, - limit: input.limit ?? 50, + const read = (effect: Effect.Effect) => + sql.withTransaction(effect).pipe( + NodeSqliteClient.readOnly, + Effect.mapError( + (cause) => + new ThreadSearchError({ + operation: Schema.isSchemaError(cause) ? "decode" : "query", + cause, }), - ) - .pipe( - NodeSqliteClient.readOnly, - Effect.mapError( - (cause) => - new ThreadSearchError({ - operation: Schema.isSchemaError(cause) ? "decode" : "query", - cause, - }), - ), - ); - return { - matches: rows.map((row) => ({ - threadId: row.threadId, - projectId: row.projectId, - source: row.source, - snippet: buildSearchSnippet(row.matchText, input.query), - messageCreatedAt: row.messageCreatedAt, - })), - }; + ), + ); + + // A project scope applies before ranking, so other projects' stronger hits + // cannot use up the limit. + const searchMessages = Effect.fn("ThreadSearch.search")(function* ( + input: OrchestrationSearchThreadsInput & { + readonly projectId: ProjectId | null; + /** Threads the caller already lists, which must not use up the limit. */ + readonly excludedThreadIds?: ReadonlySet; + /** Off for an agent's search, which has no use for the user's picks. */ + readonly picks?: boolean; }, - ); + ) { + // One message per thread: the best word match, then what the user wrote, + // since that is what they remember asking, then the newest by when it was + // written. Row order is not age: imported histories arrive in one batch. + // Text relevance (bm25) would cost a pass over every hit in history. + type Hit = { + readonly row: typeof SearchRow.Type; + /** The message as it reads, links as their text. */ + readonly text: string; + readonly quality: number; + readonly offset: number; + }; + const newer = (left: Hit, right: Hit) => + (right.row.messageCreatedAt ?? "").localeCompare(left.row.messageCreatedAt ?? ""); + const compareHits = (left: Hit, right: Hit) => + right.quality - left.quality || + Number(left.row.source !== "user") - Number(right.row.source !== "user") || + newer(left, right); + const rows = yield* read( + Effect.gen(function* () { + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + const messageSearch = buildMessageSearch(input.query, index.length > 0); + return messageSearch === null + ? [] + : yield* searchRows({ + ...messageSearch, + projectId: input.projectId, + excludedThreadIds: [...(input.excludedThreadIds ?? [])], + }); + }), + ); + const best = new Map(); + // Matching messages per thread at its best quality: a thread that keeps + // using the word ranks above a passing mention, and weaker hits ("Trust" + // for "rust") never add to a whole word's count. + const hitCounts = new Map(); + const hitCount = (threadId: ThreadId) => hitCounts.get(threadId) ?? 1; + const matchMessage = messageMatcher(input.query); + for (const row of rows) { + const text = row.text.replace(MARKDOWN_LINK, "$1"); + const match = matchMessage(text); + if (match === null) continue; + const hit = { row, text, ...match }; + const current = best.get(row.threadId); + if (current === undefined || hit.quality > current.quality) { + hitCounts.set(row.threadId, 1); + } else if (hit.quality === current.quality) { + hitCounts.set(row.threadId, hitCount(row.threadId) + 1); + } + if (current === undefined || compareHits(hit, current) < 0) best.set(row.threadId, hit); + } + // Ranked as clients rank them, with picks and use, before the limit cuts: + // a thread picked for this query must come back even when it only + // matches in a few old messages. + const opens = best.size === 0 ? [] : yield* read(threadOpens({ threadIds: [...best.keys()] })); + const subjectById = new Map( + opens.map((row) => [ + row.threadId, + { + title: "", + activityAt: row.latestUserMessageAt === null ? null : Date.parse(row.latestUserMessageAt), + opens: Option.getOrNull(decodeThreadOpens(row.opens)), + }, + ]), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const ranked = rankMessageMatches( + [...best.values()].map((hit) => ({ + ...hit, + hits: hitCount(hit.row.threadId), + messageCreatedAt: hit.row.messageCreatedAt, + })), + (hit) => subjectById.get(hit.row.threadId), + foldSearchQuery(input.query), + now, + { picks: input.picks ?? true }, + ); + return { + matches: ranked.slice(0, input.limit ?? 50).map(({ row, text, quality, offset }) => ({ + hits: hitCount(row.threadId), + threadId: row.threadId, + messageId: row.messageId, + projectId: row.projectId, + source: row.source, + snippet: buildSearchSnippet(text, offset), + messageCreatedAt: row.messageCreatedAt, + quality, + })), + }; + }); + + const search: ThreadSearch["Service"]["search"] = (input) => + searchMessages({ ...input, projectId: null }); + + const searchThreads: ThreadSearch["Service"]["searchThreads"] = Effect.fn( + "ThreadSearch.searchThreads", + )(function* (input) { + const limit = input.limit ?? 50; + const normalizedQuery = foldSearchQuery(input.query); + const now = DateTime.toEpochMillis(yield* DateTime.now); + // Agents call this rarely, and a project has hundreds of threads, not + // millions, so ranking every active thread in memory stays cheap. + const candidates = yield* read(titleCandidates({ projectId: input.projectId ?? null })); + const subjectById = new Map( + candidates.map((row): [ThreadId, ThreadSearchSubject] => [ + row.thread.id, + { + id: row.thread.id, + title: row.thread.title, + branch: row.thread.branch, + projectTitle: row.projectTitle, + pullRequests: threadSearchPullRequests(row.thread), + // Like the clients: what the user last wrote, else when the thread began. + activityAt: Date.parse( + row.latestUserMessageAt ?? DateTime.formatIso(row.thread.createdAt), + ), + opens: row.thread.opens ?? null, + }, + ]), + ); + const titleHits: ThreadSearchHit[] = rankSearchItems(candidates, (row, options) => + // An agent's search has no use for the user's palette picks. + scoreThreadSearch(normalizedQuery, subjectById.get(row.thread.id)!, now, { + ...options, + picks: false, + }), + ) + .slice(0, limit) + .map(({ item }) => ({ + threadId: item.thread.id, + projectId: item.thread.projectId, + title: item.thread.title, + source: "title", + })); + const shown = new Set(titleHits.map((hit) => hit.threadId)); + const { matches } = yield* searchMessages({ + query: input.query, + limit: 50, + projectId: input.projectId ?? null, + excludedThreadIds: shown, + picks: false, + }); + const messageHits = matches + .filter((match) => subjectById.has(match.threadId)) + .map(({ quality: _quality, hits: _hits, messageId: _messageId, ...match }) => ({ + ...match, + title: subjectById.get(match.threadId)!.title, + })); + // Titles come first, but a fifth of the slots stay for message matches, + // as the palette keeps its "In messages" section. + const messageSlots = Math.min(messageHits.length, Math.floor(limit / 5)); + return { + matches: [...titleHits.slice(0, limit - messageSlots), ...messageHits].slice(0, limit), + }; + }); - return ThreadSearch.of({ search }); + return ThreadSearch.of({ search, searchThreads }); }); export const layer = Layer.effect(ThreadSearch, make); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 10caf988e475..c16a012945c5 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -100,6 +100,33 @@ function shell(overrides: Partial = {}): SettlementShell { }; } +function persistedLink( + state: "open" | null, + linkedAt: DateTime.Utc, +): NonNullable[number] { + return { + host: "example.test", + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + source: "manual", + linkedAt: DateTime.formatIso(linkedAt), + snapshot: + state === null + ? null + : { + state, + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(linkedAt), + syncedAt: DateTime.formatIso(linkedAt), + }, + stack: null, + }; +} + describe("isAutoSettlementCandidate", () => { it("settles a thread whose only background work is a command left running", () => { expect( @@ -114,6 +141,21 @@ describe("isAutoSettlementCandidate", () => { ).toBe(true); }); + it("never settles, or keeps snoozed, a thread whose guest is waiting on the owner", () => { + expect( + ThreadSettlementService.isAutoSettlementCandidate( + shell({ pendingGuestMessageCount: 1 }), + NOW_MS, + ), + ).toBe(false); + const snoozed = shell({ + snoozedUntil: at(60 * 60 * 1_000), + snoozedAt: at(-60 * 60 * 1_000), + pendingGuestMessageCount: 1, + }); + expect(ThreadSettlementService.isSnoozed(snoozed, NOW_MS)).toBe(false); + }); + it("keeps snoozed threads parked until a fresh failure or timer wake", () => { const snoozed = shell({ snoozedUntil: at(60 * 60 * 1_000), @@ -186,6 +228,51 @@ describe("resolveAutoSettlementAt", () => { ).toEqual(at(-4 * DAY_MS)); }); + it("settles an idle thread with an open persisted link only while retention is off", () => { + const input = { + thread: shell({ + latestUserMessageAt: at(-4 * DAY_MS), + pullRequests: [persistedLink("open", at(-5 * DAY_MS))], + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + autoSettleKeepPullRequests: false, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-4 * DAY_MS)); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + autoSettleKeepPullRequests: true, + }), + ).toBeNull(); + // The open link outranks a merged lookup, so only inactivity can settle it. + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + autoSettleAfterDays: null, + }), + ).toBeNull(); + }); + + it("keeps an idle thread whose persisted link was never synced", () => { + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread: shell({ + latestUserMessageAt: at(-4 * DAY_MS), + pullRequests: [persistedLink(null, at(-5 * DAY_MS))], + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + autoSettleKeepPullRequests: false, + }), + ).toBeNull(); + }); + it("uses the latest activity time when the inactivity window elapses", () => { const idle = shell({ latestUserMessageAt: at(-4 * DAY_MS), @@ -330,8 +417,6 @@ describe("resolveAutoSettlementAt", () => { }, stack: null, }); - // The user asked the agent to merge pull requests; it merged one, then - // linked it to the thread as a record of the work. const recorded = link(1, at(-50 * 60 * 1_000), at(-55 * 60 * 1_000)); const input = { thread: shell({ @@ -345,7 +430,6 @@ describe("resolveAutoSettlementAt", () => { autoSettleOnMerge: true, }; expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); - // A pull request linked while open still settles the thread when it merges. expect( ThreadSettlementService.resolveAutoSettlementAt({ ...input, @@ -697,6 +781,32 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("settles an idle thread with an open persisted link on the inactivity sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const thread = makeThread("open-link", { + pullRequests: [persistedLink("open", DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"))], + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([thread]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleKeepPullRequests: false, + }, + }); + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + thread.id, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("settles only the project opted in while environment settlement is disabled", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 7d95dfd035a3..bd5b7d346fb1 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -26,6 +26,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectStore from "./ProjectStore.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -51,18 +52,6 @@ function latestMillis(values: ReadonlyArray): number | null { return latest; } -function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} - function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: { @@ -72,8 +61,8 @@ function pullRequestMatchesProject( return ( pullRequest.repositoryKey !== null && project.repositoryIdentity != null && - canonicalRepositoryKey(pullRequest.repositoryKey) === - canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + sourceControlDefinitions.canonicalRepositoryKey(pullRequest.repositoryKey) === + sourceControlDefinitions.canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) ); } @@ -89,12 +78,7 @@ function linkTerminalAtMs(link: SettlementPullRequestLink): number { return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; } -/** - * The links whose outcome the thread waits on. A link made after its pull - * request had already merged or closed records work the thread did, such as an - * agent merging pull requests it was asked to merge, so it cannot settle the - * thread. - */ +/** Exclude links added after merge or close; they record completed work rather than outcomes this thread awaits. */ function settlementPullRequestLinks( links: ReadonlyArray, ): ReadonlyArray { @@ -105,10 +89,7 @@ function settlementPullRequestLinks( }); } -/** - * Absolute age bounds client clock skew in both directions and stops stale - * pre-adoption data from blocking the thread forever. - */ +/** Absolute age bounds clock skew in both directions and expires stale pre-adoption data. */ export function threadHasQueuedTurnStart( thread: Pick< OrchestrationV2ThreadShell, @@ -163,6 +144,8 @@ export function isAutoSettlementCandidate( if (thread.archivedAt !== null || thread.settledOverride !== null) return false; if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; if (thread.pendingRuntimeRequest !== null) return false; + // A guest is waiting on the owner; settling would hide the Request. + if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; if (thread.activityRunStatus != null) return false; if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; @@ -170,20 +153,24 @@ export function isAutoSettlementCandidate( return !isSnoozed(thread, nowMs); } -/** - * Server twin of the client's `effectiveSnoozed`, so agents and the sidebar agree on what is - * snoozed. - */ +/** Match client effectiveSnoozed so agents and sidebar agree. */ export function isSnoozed( thread: Pick< ProjectionStore.ProjectionSettlementCandidate, - "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest" + | "snoozedUntil" + | "snoozedAt" + | "latestRunCompletedAt" + | "status" + | "pendingRuntimeRequest" + | "pendingGuestMessageCount" >, nowMs: number, ): boolean { const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false; if (thread.pendingRuntimeRequest !== null) return false; + // Same as the client: a waiting guest message wakes the thread, and the snooze stays stored. + if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; const snoozedAtMs = toMillis(thread.snoozedAt); const completedAtMs = toMillis(thread.latestRunCompletedAt); const wokeOnError = @@ -203,8 +190,13 @@ export function resolveAutoSettlementAt(input: { const { thread } = input; let pullRequest = input.pullRequest; const links = settlementPullRequestLinks(thread.pullRequests ?? []); - if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; - if (links.length > 0) { + if (links.some((link) => link.snapshot === null)) return null; + // An open link means the pull request has not finished, so only inactivity + // can settle the thread, never a merged or closed lookup. + const hasOpenLink = links.some((link) => link.snapshot?.state === "open"); + if (hasOpenLink) { + pullRequest = null; + } else if (links.length > 0) { const latest = links.reduce((current, candidate) => linkTerminalAtMs(candidate) > linkTerminalAtMs(current) ? candidate : current, ); @@ -229,7 +221,10 @@ export function resolveAutoSettlementAt(input: { return DateTime.makeUnsafe(activityAtMs); } const hasPullRequest = - pullRequest !== null || thread.linkedPullRequest != null || thread.branchPullRequest != null; + hasOpenLink || + pullRequest !== null || + thread.linkedPullRequest != null || + thread.branchPullRequest != null; if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null; if (input.autoSettleAfterDays === null) return null; const snoozedUntilMs = toMillis(thread.snoozedUntil); @@ -299,8 +294,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const terminals = yield* TerminalManager.TerminalManager; const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - // Settling a settled thread re-emits thread.settled with the same settledAt, - // so this keeps the settle action to one run per settlement. + // Repeated thread.settled retains settledAt; run the action only once per settlement. const settleActionRunAt = new Map(); const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( @@ -316,10 +310,7 @@ export const make = Effect.gen(function* () { const projectShells = yield* projectStore.listShells(); const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const projects = new Map(projectShells.map((project) => [project.id, project])); - // A merge event re-sweeps every candidate, not just the threads linked to - // the merged pull request: most threads carry no link and settle from - // their branch lookup, which would otherwise wait for the next minute's - // sweep on a possibly stale cached answer. + // 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")( @@ -388,10 +379,7 @@ export const make = Effect.gen(function* () { { concurrency: 8, discard: true }, ); if (mergedPullRequest !== null) { - // The merge just confirmed a terminal state the lookup caches can still - // call open (branch answers live two minutes, the sweep runs every - // minute). Drop the swept checkouts' cached answers so the merge settles - // its branch threads now instead of on a later sweep. + // Invalidate checkout caches on merge; cached open branches would delay settlement beyond this sweep. const cwds = [...new Set(lookupCwdByThreadId.values())]; yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { concurrency: 8, @@ -445,11 +433,7 @@ export const make = Effect.gen(function* () { const thread = group[0]!; const reference = thread.linkedPullRequest ?? thread.branchPullRequest; if (reference != null) { - // The event carries the merged state, so only the threads linked to - // that exact pull request settle from it. Every other linked thread - // falls through to a fresh summary lookup below: the merge sweep - // covers all candidates, and an unrelated merge must never settle - // them. + // Only matching PR links settle from this merge event; unrelated links require fresh lookup. const matchesMerge = mergedPullRequest !== null && reference.projectId === mergedPullRequest.projectId && @@ -543,9 +527,7 @@ export const make = Effect.gen(function* () { runSweep(null, threadId), ); - // Then the project's settle script runs in the thread's own worktree; a - // thread in the shared checkout skips it, because other threads may still be - // working there. + // Skip settle scripts in shared checkouts where other threads may still work. const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")( function* (threadId: ThreadId) { const settled = yield* projections.getThread(threadId); @@ -553,8 +535,7 @@ export const make = Effect.gen(function* () { yield* terminals.closeIdle({ threadId }); const worktreePath = settled.worktreePath; if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return; - // Closing and the worktree check wait on I/O. A thread re-engaged - // meanwhile is working again, so its worktree is no place for cleanup. + // Recheck after closing and worktree I/O; re-engaged threads must not be cleaned up. const thread = yield* projections.getThread(threadId); if (thread.settledOverride !== "settled") return; const settledAtMs = toMillis(thread.settledAt); diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 5952e5c217a3..0c38faa5929f 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1097,7 +1097,6 @@ it.effect.each(["thread.stop", "run.interrupt"] as const)( creationSource: "server", restartContinuationOfRunId: source.id, }); - // The held run resumed after the cancelled source ran, so it sorts after it. assert.deepEqual(yield* threadState(threadId), { runs: ["completed", "cancelled", "interrupted"], watched: [], diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index 438a24743a73..a5059eada443 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -161,7 +161,6 @@ describe("decideThreadResume", () => { expect(snapshot.historyCursor).not.toBeNull(); expect(snapshot.hasMoreHistory).toBe(true); expect(snapshot.latestLocalTurnOrdinal).toBe(80); - expect(snapshot.payloadBudgetExceeded).toBe(false); }); it("rejects a 10 MiB raw replay even when its projected form fits the wire budget", () => { diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 4ef21edd3b3c..8e8323bcff76 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -4,6 +4,7 @@ import type { } from "@t3tools/contracts"; import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; +import { dedupeSnapshotBodies } from "@t3tools/shared/orchestrationV2DedupedBodies"; import { buildBoundedThreadProjection, @@ -15,10 +16,7 @@ export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; export const THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES = 1_048_576; -/** - * Keep the original preflight budget: decoded objects and temporary strings - * cost more than their stored JSON, even if wire projection shrinks them later. - */ +/** Decoded objects and temporary strings exceed stored JSON size, even before wire slimming. */ export const THREAD_RESUME_MAX_RAW_PAYLOAD_BYTES = 1_048_576; export function isThreadReplayRawPayloadSafe(rawPayloadBytes: number): boolean { @@ -42,17 +40,19 @@ type ThreadSnapshotStreamItem = Extract< export function boundedSnapshotResponseFields(input: { readonly bounded: BoundedProjectionResult; readonly compactTurnItems: boolean; + readonly dedupeBodies: boolean; }) { const { bounded } = input; - const compactProjection = input.compactTurnItems - ? omitLocalVisibleTurnItems(bounded.projection) - : null; + // Dedupe keeps shared items one object, which the compaction's identity check needs. + const projection = input.dedupeBodies + ? dedupeSnapshotBodies(bounded.projection) + : bounded.projection; + const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(projection) : null; return { - projection: compactProjection ?? bounded.projection, + projection: compactProjection ?? projection, historyCursor: bounded.historyCursor, hasMoreHistory: bounded.hasMoreHistory, latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: bounded.payloadBudgetExceeded, ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), }; } @@ -61,6 +61,7 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; readonly compactTurnItems?: boolean; + readonly dedupeBodies?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -72,6 +73,7 @@ export function buildBoundedThreadStreamSnapshot(input: { ...boundedSnapshotResponseFields({ bounded, compactTurnItems: input.compactTurnItems === true, + dedupeBodies: input.dedupeBodies === true, }), }; } @@ -84,10 +86,7 @@ export type ThreadResumePlan = } | { readonly mode: "snapshot" }; -/** - * Event count limits reducer churn while encoded bytes limit a small number of - * large updates. Either excess is cheaper to replace with one current snapshot. - */ +/** Bound event count and encoded bytes; replace excessive replay with one current snapshot. */ export function decideThreadResume(input: { readonly afterSequence: number; readonly highWater: number; diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 17213d37810a..baf1ea0a3cb7 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -9,7 +9,9 @@ import { ProviderInstanceId, ThreadId, TurnItemId, + type OrchestrationV2DomainEvent, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadStreamItem, OrchestrationV2TurnItem, OrchestrationV2TurnItemJson, } from "@t3tools/contracts"; @@ -17,7 +19,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; +import { coalesceLiveUpdatedEvents } from "./ThreadLiveEventCoalescer.ts"; import { + makeLiveBodyDeduper, projectTurnItemForWire, projectTurnItemForDetail, projectDomainEventForWire, @@ -302,3 +306,74 @@ describe("orchestration V2 wire projection", () => { expect(projectDomainEventForWire(projected)).toEqual(projected); }); }); + +describe("live body deduper", () => { + const at = base.updatedAt; + const messageId = MessageId.make("message-live"); + const event = ( + sequence: number, + rest: Pick, + ): Extract => ({ + kind: "event", + sequence, + event: { + id: EventId.make(`event-${sequence}`), + threadId: base.threadId, + occurredAt: at, + ...rest, + } as OrchestrationV2DomainEvent, + }); + const message = (sequence: number, text: string) => + event(sequence, { + type: "message.updated", + payload: { + id: messageId, + threadId: base.threadId, + runId: null, + nodeId: null, + role: "assistant", + text, + attachments: [], + streaming: true, + createdBy: "agent", + creationSource: "provider", + createdAt: at, + updatedAt: at, + }, + }); + const reply = (sequence: number, text: string) => + event(sequence, { + type: "turn-item.updated", + payload: { + ...base, + status: "running", + type: "assistant_message", + messageId, + text, + streaming: true, + }, + }); + const payloadOf = (item: OrchestrationV2ThreadStreamItem) => + item.kind === "event" ? item.event.payload : undefined; + + it("blanks a reply that repeats the message delivered before it, after coalescing", () => { + const delivered = coalesceLiveUpdatedEvents([ + message(1, "a"), + reply(2, "a"), + message(3, "ab"), + reply(4, "ab"), + ]).map(makeLiveBodyDeduper()); + + expect( + delivered.map((item) => [item.kind === "event" && item.sequence, payloadOf(item)]), + ).toEqual([ + [3, message(3, "ab").event.payload], + [4, { ...reply(4, "ab").event.payload, text: "", textOmitted: true }], + ]); + }); + + 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 043fbd3435ac..a84f8cd46685 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -1,9 +1,19 @@ import type { + CheckpointId, + MessageId, + NodeId, + OrchestrationV2Checkpoint, OrchestrationV2DomainEvent, OrchestrationV2ContextHandoff, + OrchestrationV2Subagent, OrchestrationV2ThreadProjection, + OrchestrationV2ThreadStreamItem, OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import { + dedupeSubagentBodies, + dedupeTurnItemBodies, +} from "@t3tools/shared/orchestrationV2DedupedBodies"; import { compactDynamicToolOutput, omitToolOutputImageData, @@ -25,8 +35,7 @@ function truncateDetail( ) { return value; } - // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix - // that could fit, rather than allocating a buffer for the complete output. + // UTF-8 needs at least one byte per UTF-16 unit; encode only a potentially fitting prefix. const prefix = Buffer.from(value.slice(0, maxBytes), "utf8") .subarray(0, maxBytes) .toString("utf8") @@ -34,11 +43,7 @@ function truncateDetail( return `${prefix}\n… output truncated for transport`; } -/** - * Clients never receive more than this (see projectTurnItemForWire), so a full - * diff of a large file would only grow the database and the heap that reads it - * back. - */ +/** Bound stored diffs to the client wire limit to avoid retaining invisible data in SQLite and heap. */ export function boundFileChangeForStorage(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { if (item.type !== "file_change") return item; const { diffStr, oldStr, newStr } = item; @@ -159,10 +164,7 @@ function boundDynamicValue(value: unknown): unknown { : truncateDetail(json, MAX_ON_DEMAND_BYTES); } -/** - * When the rest is too large to send whole, its truncated text still comes with - * the image markers, in order, so clients can load each image by index. - */ +/** Preserve ordered image markers in truncated output so clients can load images by index. */ function boundToolOutput(output: unknown): unknown { const omitted = omitToolOutputImageData(output); const bounded = boundDynamicValue(omitted); @@ -245,3 +247,64 @@ export function projectDomainEventForWire( ? { ...event, payload: projectContextHandoffForWire(event.payload) } : event; } + +const LIVE_DEDUPE_MEMORY = 32; + +function remember(memory: Map, key: K, value: V): void { + memory.delete(key); + memory.set(key, value); + if (memory.size > LIVE_DEDUPE_MEMORY) memory.delete(memory.keys().next().value as K); +} + +/** + * One per subscription, after coalescing, so it remembers only records the client received. A + * record event comes before the turn item that repeats its body, so the item (or a repeated + * subagent record) ships blank and marked. The other order, or a forgotten record, only costs + * bytes: the repeat ships whole. + */ +export function makeLiveBodyDeduper(): ( + item: OrchestrationV2ThreadStreamItem, +) => OrchestrationV2ThreadStreamItem { + const messageText = new Map(); + const checkpointFiles = new Map(); + const subagents = new Map>(); + const source = { + messageText: (id: MessageId) => messageText.get(id), + checkpointFiles: (id: CheckpointId) => checkpointFiles.get(id), + subagent: (id: NodeId) => subagents.get(id), + }; + const rememberSubagent = ({ id, prompt, result }: OrchestrationV2Subagent) => + remember(subagents, id, { prompt, result }); + return (item) => { + if (item.kind === "snapshot") { + // The client now holds exactly these records; their bodies are never blanked. + const { messages, checkpoints, subagents: records } = item.projection; + for (const message of messages) remember(messageText, message.id, message.text); + for (const checkpoint of checkpoints) + remember(checkpointFiles, checkpoint.id, checkpoint.files); + for (const subagent of records) rememberSubagent(subagent); + return item; + } + if (item.kind !== "event") return item; + const { event } = item; + switch (event.type) { + case "message.updated": + remember(messageText, event.payload.id, event.payload.text); + return item; + case "checkpoint.captured": + remember(checkpointFiles, event.payload.id, event.payload.files); + return item; + case "subagent.updated": { + const payload = dedupeSubagentBodies(event.payload, subagents.get(event.payload.id)); + rememberSubagent(event.payload); + return payload === event.payload ? item : { ...item, event: { ...event, payload } }; + } + case "turn-item.updated": { + const payload = dedupeTurnItemBodies(event.payload, source); + return payload === event.payload ? item : { ...item, event: { ...event, payload } }; + } + default: + return item; + } + }; +} diff --git a/apps/server/src/orchestration-v2/assistantStreaming.ts b/apps/server/src/orchestration-v2/assistantStreaming.ts index ee14f363d9af..62a173d7318c 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.ts @@ -1,30 +1,17 @@ import type { ResponseStreamingMode } from "@t3tools/contracts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -// An opening fence may sit at any indentation, since fences inside list items -// are indented past the marker. A closing fence may be indented at most three -// spaces more than its opener. +// List fences can open at any indentation; closers may be at most three spaces deeper. const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; -// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as -// a no-break space, is paragraph content. +// CommonMark blank lines contain only spaces and tabs. const BLANK_LINE_PATTERN = /^[ \t]*$/; -// The trailing space is required, so a partial `-` or `1.` never matches before -// the model finishes the marker. +// Require trailing space so partial list markers cannot match during streaming. const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/; -// An unindented ATX heading ends the paragraph or list above it, even with no -// blank line between them. A bold line would continue the paragraph instead. +// Unindented ATX headings end preceding paragraphs and lists without blank lines; bold lines do not. const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/; -/** - * Only fully terminated lines count, so a trailing partial line never leaks; a - * list item start is the one lookahead that may sit on the partial line, since - * tight lists have no blank lines between items and would otherwise land all at - * once. - * - * A section title holds the boundary until a content line follows it, so a - * title never lands alone and waits above a block that is still streaming. - */ +/** Emit terminated lines only, except partial list-start lookahead for tight lists. Hold section titles until content follows. */ export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { let openFence: { marker: string; indent: number } | null = null; let boundary = -1; diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index 9519b661cbf3..ec22a06a1935 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -2,6 +2,9 @@ import { assert, describe, it } from "@effect/vitest"; import { AuthOrchestrationReadScope, AuthSessionId, + CheckpointId, + CheckpointRef, + CheckpointScopeId, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi, @@ -20,6 +23,8 @@ import { ThreadId, TurnItemId, type OrchestrationV2DomainEvent, + type OrchestrationV2StoredEvent, + type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; @@ -59,8 +64,10 @@ const projectId = ProjectId.make("project:compact-transport"); const PARENT = ThreadId.make("thread:compact-transport:parent"); const FORK = ThreadId.make("thread:compact-transport:fork"); const LONG = ThreadId.make("thread:compact-transport:long"); +const BODIES = ThreadId.make("thread:compact-transport:bodies"); const parentRun = RunId.make("run:compact-transport:parent"); const longRun = RunId.make("run:compact-transport:long"); +const bodiesRun = RunId.make("run:compact-transport:bodies"); const store = Layer.mergeAll( ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), @@ -74,7 +81,8 @@ const management = Layer.unwrap( getThreadSnapshot: (id) => projections.getThreadSnapshot(id).pipe(Effect.orDie), getThreadSnapshotWindow: (id, options) => projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), - streamStoredEventsFrom: () => Stream.empty, + streamStoredEventsFrom: (input) => + input?.threadId === BODIES ? Stream.fromIterable(bodiesLiveTail()) : Stream.empty, }); }), ); @@ -203,6 +211,157 @@ function itemUpdated( }; } +// A conversation turn whose records (message, checkpoint, subagent) repeat in its turn items. +function bodiesEvents(at: DateTime.Utc): OrchestrationV2DomainEvent[] { + const event = (key: string, rest: Pick) => + ({ + id: EventId.make(`event:${BODIES}:${key}`), + threadId: BODIES, + runId: bodiesRun, + occurredAt: at, + ...rest, + }) as OrchestrationV2DomainEvent; + const item = (key: string, ordinal: number, fields: object) => + event(`${key}-item`, { + type: "turn-item.updated", + payload: { + id: TurnItemId.make(`turn-item:${BODIES}:${key}`), + threadId: BODIES, + runId: bodiesRun, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + ...fields, + } as OrchestrationV2TurnItem, + }); + const message = (key: string, role: "user" | "assistant", text: string) => + event(`${key}-message`, { + type: "message.updated", + payload: { + id: MessageId.make(`message:${key === "prompt" ? bodiesRun : `${BODIES}:${key}`}`), + threadId: BODIES, + runId: bodiesRun, + nodeId: null, + role, + text, + attachments: [], + streaming: false, + createdBy: role === "user" ? "user" : "agent", + creationSource: role === "user" ? "web" : "provider", + createdAt: at, + updatedAt: at, + }, + }); + const files = [{ path: "src/index.ts", kind: "modified", additions: 4, deletions: 2 }]; + return [ + threadCreated(BODIES, at), + runCreated(BODIES, bodiesRun, at), + message("prompt", "user", "Please fix the build."), + item("prompt", 1, { + type: "user_message", + messageId: MessageId.make(`message:${bodiesRun}`), + inputIntent: "turn_start", + text: "Please fix the build.", + attachments: [], + createdBy: "user", + creationSource: "web", + }), + event("task-record", { type: "subagent.updated", payload: bodiesSubagent(at) }), + item("task", 2, { + type: "subagent", + subagentId: bodiesSubagent(at).id, + origin: "app_owned", + driver: "codex", + providerInstanceId: provider, + childThreadId: null, + prompt: bodiesSubagent(at).prompt, + result: bodiesSubagent(at).result, + }), + message("reply", "assistant", "Fixed the import."), + item("reply", 3, { + type: "assistant_message", + messageId: MessageId.make(`message:${BODIES}:reply`), + text: "Fixed the import.", + streaming: false, + }), + event("checkpoint-record", { + type: "checkpoint.captured", + payload: { + id: CheckpointId.make(`checkpoint:${BODIES}`), + threadId: BODIES, + scopeId: CheckpointScopeId.make(`scope:${BODIES}`), + runId: bodiesRun, + nodeId: NodeId.make(`node:${bodiesRun}`), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: 1, + ref: CheckpointRef.make(`refs/t3/${BODIES}`), + status: "ready", + files, + capturedAt: at, + }, + }), + item("checkpoint", 4, { + type: "checkpoint", + checkpointId: CheckpointId.make(`checkpoint:${BODIES}`), + scopeId: CheckpointScopeId.make(`scope:${BODIES}`), + files, + }), + ]; +} + +function bodiesSubagent(at: DateTime.Utc): OrchestrationV2Subagent { + return { + id: NodeId.make(`node:${BODIES}:task`), + threadId: BODIES, + runId: bodiesRun, + parentNodeId: NodeId.make(`node:${bodiesRun}`), + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: provider, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Find the failing import.", + title: null, + model: null, + status: "completed", + result: "It was src/index.ts.", + startedAt: at, + completedAt: at, + updatedAt: at, + }; +} + +// Live after the snapshot: a reply pair, then a subagent update that repeats its prompt. +function bodiesLiveTail(): OrchestrationV2StoredEvent[] { + const at = DateTime.makeUnsafe("2026-10-01T00:00:00.000Z"); + const [reply, replyItem] = bodiesEvents(at).filter((event) => + String(event.id).includes(":reply-"), + ); + return [ + reply!, + replyItem!, + { + id: EventId.make(`event:${BODIES}:task-progress`), + type: "subagent.updated", + threadId: BODIES, + runId: bodiesRun, + occurredAt: at, + payload: { ...bodiesSubagent(at), progress: "Checked the build." }, + } as OrchestrationV2DomainEvent, + ].map((event, index) => ({ sequence: 1_000 + index, commandId: null, event })); +} + const seed = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const at = yield* DateTime.now; @@ -219,6 +378,7 @@ const seed = Effect.gen(function* () { itemUpdated(LONG, longRun, `tool-${index}`, index + 2, at), ), itemUpdated(LONG, longRun, "result", 92, at, "run_interrupt_result"), + ...bodiesEvents(at), ]; for (const event of events) yield* projections.apply(event); }); @@ -365,4 +525,70 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { }), ); }); + + describe("deduped bodies", () => { + const markers = ["textOmitted", "filesOmitted", "promptOmitted", "resultOmitted"]; + + it.effect( + "HTTP: opted-in snapshots blank repeated bodies and restore to the same snapshot", + () => + withHttp(async (get) => { + const base = `/api/orchestration/threads/${encodeURIComponent(BODIES)}/bounded`; + const compactBody = await (await get(`${base}?compactTurnItems=1`)).text(); + const dedupedBody = await (await get(`${base}?compactTurnItems=1&dedupeBodies=1`)).text(); + + for (const marker of markers) { + assert.notInclude(compactBody, marker); + assert.include(dedupedBody, marker); + } + assert.isBelow(dedupedBody.length, compactBody.length); + assert.deepStrictEqual( + boundedSnapshotProjection(decodeBounded(dedupedBody)), + boundedSnapshotProjection(decodeBounded(compactBody)), + ); + }), + ); + + it.effect( + "WS: bounded and full snapshots restore, and the live tail sends each body once", + () => + Effect.gen(function* () { + const subscribe = ( + input: Partial[0]>, + ) => + subscribeOrchestrationV2Thread({ threadId: BODIES, ...input }).pipe( + Effect.flatMap(Stream.runCollect), + Effect.map((items) => + Array.from(items, (item) => + decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(item)))), + ), + ), + ); + const bounded = { acceptBoundedSnapshot: true, acceptCompactTurnItems: true }; + for (const mode of [bounded, {}]) { + const [plainSnapshot, ...plainTail] = yield* subscribe(mode); + const [dedupedSnapshot, ...dedupedTail] = yield* subscribe({ + ...mode, + acceptDedupedBodies: true, + }); + if (plainSnapshot?.kind !== "snapshot" || dedupedSnapshot?.kind !== "snapshot") { + throw new Error("No snapshot"); + } + assert.deepStrictEqual( + boundedSnapshotProjection(dedupedSnapshot), + boundedSnapshotProjection(plainSnapshot), + ); + const payloads = (tail: typeof plainTail) => + tail.map((item) => (item.kind === "event" ? item.event.payload : null)); + const [reply, replyItem, progress] = payloads(plainTail); + assert.deepStrictEqual(payloads(dedupedTail), [ + reply, + { ...replyItem, text: "", textOmitted: true }, + // The snapshot delivered this subagent's record, so its unchanged bodies repeat. + { ...progress, prompt: "", promptOmitted: true, result: null, resultOmitted: true }, + ]); + } + }), + ); + }); }); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index cc8e987880df..818698af746d 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -30,7 +30,11 @@ import { } from "./threadHistoryPaging.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import { buildActiveShellSnapshot, loadShellSnapshotParts } from "./ShellStream.ts"; +import { + buildActiveShellSnapshot, + loadShellSnapshotParts, + omitIdleSubagentShells, +} from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -62,9 +66,7 @@ export const layer = HttpApiBuilder.group( Effect.forEach( projects, (project) => - // Use immediately available enrichment only. Awaiting git-backed - // identity resolution can exceed the client shell-snapshot budget - // (ProcessRunner allows probes up to one minute). + // Use available enrichment only; git probes can take a minute and exceed shell-snapshot budgets. projectEnrichment.getAvailable(project.workspaceRoot).pipe( Effect.map((enrichment) => ({ ...project, @@ -75,7 +77,9 @@ export const layer = HttpApiBuilder.group( ), ); - const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* () { + const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* ( + omitIdleSubagents: boolean, + ) { const base = buildActiveShellSnapshot( yield* loadShellSnapshotParts({ sql, @@ -85,7 +89,11 @@ export const layer = HttpApiBuilder.group( }), ); const projects = yield* enrichProjectShells(base.projects); - return { ...base, projects }; + return { + ...base, + threads: omitIdleSubagents ? omitIdleSubagentShells(base.threads) : base.threads, + projects, + }; }); const loadThreadSnapshot = Effect.fn("http.orchestration.loadThreadSnapshot")(function* ( @@ -154,7 +162,7 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* loadShellSnapshot().pipe( + return yield* loadShellSnapshot(args.query.omitIdleSubagents === "1").pipe( traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), @@ -219,6 +227,7 @@ export const layer = HttpApiBuilder.group( ...boundedSnapshotResponseFields({ bounded, compactTurnItems: args.query.compactTurnItems === "1", + dedupeBodies: args.query.dedupeBodies === "1", }), }; }), diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index 7a5542e65bad..5d2343f6a1ea 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -80,13 +80,7 @@ const codexModelSelection = { model: "gpt-5.4", }; -/** - * Slot 41 carries a site-local `ThreadSummaryTimeline` migration, matching - * production databases where local builds recorded extra names under the shared - * id sequence. The V2 runner only applies migrations past the recorded maximum - * id, so the cutover in this test applies 050, 051 and 052 on top of the - * untouched copy — the same path the real upgrade takes. - */ +/** Slot 41 models site-local migrations sharing production ids; the V2 runner applies only ids above the recorded maximum. */ const seedV1Database = (fixturePath: string, workspace: string) => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index 610be4c1ba3a..ee4baf0abf83 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -1,7 +1,7 @@ import { threadPullRequestKeysEqual, threadPullRequestsOf, -} from "@t3tools/shared/threadPullRequests"; +} from "../../sourceControl/threadPullRequests.ts"; import { ChatAttachment, OrchestrationMessageContext, @@ -677,10 +677,7 @@ const make = Effect.gen(function* () { ), ); - // `transcript_imported_at` is never reset to NULL, so a positive answer - // stays valid for the process lifetime; ensureTranscript runs on most - // thread reads and command dispatches, so skipping the lock + lookup here - // keeps that path off the database entirely after first confirmation. + // transcript_imported_at never resets; cache confirmed imports for the process lifetime to skip repeated locks and reads. const confirmedTranscriptThreadIds = new Set(); const ensureTranscriptBase = (threadId: ThreadId) => @@ -701,8 +698,7 @@ const make = Effect.gen(function* () { return { importedThreadCount: 0, importedMessageCount: 0 }; } const messages = yield* listMessages(threadId); - // The thread's V1 events are still in the table here. Without the hint - // SQLite walks them through the stream index and reads past each payload. + // Hint the index to skip V1 events without scanning their payloads. const existingRows = yield* sql<{ readonly event_id: string }>` SELECT event_id FROM orchestration_events INDEXED BY idx_orchestration_events_agent_stream_sequence diff --git a/apps/server/src/orchestration-v2/messageSearchIndex.ts b/apps/server/src/orchestration-v2/messageSearchIndex.ts new file mode 100644 index 000000000000..318a92e9efd5 --- /dev/null +++ b/apps/server/src/orchestration-v2/messageSearchIndex.ts @@ -0,0 +1,65 @@ +import * as Effect from "effect/Effect"; +import type * as SqlClient from "effect/sql/SqlClient"; + +/** + * Exclude generated notices and subagent threads: search hides them, and ACP subagents rewrite + * their finished answer on every streamed chunk. + * Migration triggers, backfill, rebuild and scans share this predicate; `row` may be `new`. + */ +export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "messages") => + sql.literal(` + ${row}.streaming = 0 + AND ( + ${row}.role = 'assistant' + OR ( + ${row}.role = 'user' + AND COALESCE(json_extract(${row}.payload_json, '$.createdBy'), 'user') = 'user' + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_threads AS subagent_threads + WHERE subagent_threads.thread_id = ${row}.thread_id + AND json_extract(subagent_threads.payload_json, '$.lineage.relationshipToParent') = 'subagent' + ) +`); + +/** + * What the index stores for a message: its text with curly apostrophes + * straight, as search folds a query, since iOS keyboards type "’" and others + * type "'". Migration 69's triggers use it too. `payload` is the message's + * JSON column. + */ +export const indexedMessageText = (sql: SqlClient.SqlClient, payload: string) => + sql.literal(`replace(replace(json_extract(${payload}, '$.text'), '’', ''''), '‘', '''')`); + +/** + * Refills the search index from the messages table. The index is keyed by + * message rowid, so anything that may renumber or copy rows (VACUUM, a dev + * database copy) rebuilds it instead of trusting it. Databases from before + * migration 69, or on an SQLite without FTS5 trigrams, have no index. + */ +export const rebuildMessageSearch = Effect.fn("MessageSearchIndex.rebuild")(function* ( + sql: SqlClient.SqlClient, +) { + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + if (index.length === 0) return; + // One transaction: an interrupt between clear and refill would leave an + // empty index that nothing repairs, since search only checks the table exists. + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql` + INSERT INTO orchestration_v2_message_search (orchestration_v2_message_search) + VALUES ('delete-all') + `; + yield* sql` + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT messages.rowid, ${indexedMessageText(sql, "messages.payload_json")} + FROM orchestration_v2_projection_messages AS messages + WHERE ${indexedMessageCondition(sql)} + AND json_extract(messages.payload_json, '$.text') IS NOT NULL + `; + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts deleted file mode 100644 index 4301e38fe24c..000000000000 --- a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { SourceControlProviderKind, type ThreadPullRequestWatch } from "@t3tools/contracts"; -import { assert, describe, it } from "@effect/vitest"; -import * as Schema from "effect/Schema"; -import { - ForgejoComment, - forgejoComment, -} from "@t3tools/source-control-forgejo/server/forgejoPullRequestJson"; - -import { evaluatePullRequestWatch as evaluate } from "./pullRequestWatch.ts"; - -// These cases read the head once or at one moment, so the no-checks notice never comes due. -const evaluatePullRequestWatch = ( - ...args: Parameters extends [...infer Head, number] ? Head : never -) => evaluate(...args, 0); - -const decodeForgejoComment = Schema.decodeSync(ForgejoComment); - -describe("Forgejo comment watch boundary", () => { - const createdAt = "2026-10-10T19:52:40Z"; - const editedAt = "2026-10-10T20:04:50Z"; - const seen = { - startedAt: "2026-10-02T12:00:00.000Z", - headSha: "aaaaaaaaaa", - failedChecks: [], - passed: false, - passedChecks: [], - remarksThrough: "2026-10-10T19:52:40.000Z", - remarkIds: ["29061"], - conflicting: false, - wakes: 2, - } satisfies ThreadPullRequestWatch; - const finalDetail = { - provider: SourceControlProviderKind.make("forgejo"), - headSha: seen.headSha, - checks: [], - mergeability: "mergeable", - viewer: "agent-user", - author: { login: "agent-user", name: null, avatarUrl: null }, - } satisfies Parameters[1]; - const decodedComment = (updatedAt: string | null | undefined) => - forgejoComment( - decodeForgejoComment({ - id: 29061, - body: "Performance: five rounds complete", - user: { login: "forgejo-actions" }, - created_at: createdAt, - ...(updatedAt === undefined ? {} : { updated_at: updatedAt }), - }), - ); - - it.each([undefined, null, createdAt, "2026-10-10T19:52:40.000Z"])( - "does not wake for an unchanged comment with updated_at %s", - (updatedAt) => { - const comment = decodedComment(updatedAt); - assert.isUndefined(comment.editedAt); - assert.deepEqual(evaluatePullRequestWatch(seen, finalDetail, [comment]).changes, []); - }, - ); - - it("wakes once for the edit of an already-seen benchmark comment", () => { - const finalComment = decodedComment(editedAt); - assert.strictEqual(finalComment.editedAt, "2026-10-10T20:04:50.000Z"); - assert.isUndefined(decodedComment(createdAt).editedAt); - const completed = evaluatePullRequestWatch(seen, finalDetail, [finalComment]); - assert.deepEqual(completed.changes, [{ kind: "remarks", remarks: [finalComment] }]); - assert.strictEqual(completed.next.remarksThrough, "2026-10-10T20:04:50.000Z"); - assert.deepEqual( - evaluatePullRequestWatch(completed.next, finalDetail, [finalComment]).changes, - [], - ); - assert.deepEqual( - evaluatePullRequestWatch(seen, finalDetail, [decodedComment(createdAt)]).changes, - [], - ); - const secondEdit = decodedComment("2026-10-10T20:05:50Z"); - const second = evaluatePullRequestWatch(completed.next, finalDetail, [secondEdit]); - assert.deepEqual(second.changes, [{ kind: "remarks", remarks: [secondEdit] }]); - assert.deepEqual(evaluatePullRequestWatch(second.next, finalDetail, [secondEdit]).changes, []); - assert.deepEqual( - evaluatePullRequestWatch(seen, finalDetail, [ - { ...finalComment, author: { login: "agent-user", name: null, avatarUrl: null } }, - ]).changes, - [], - ); - }); -}); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index ea27a0fffe58..0b6070f36466 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -1,5 +1,4 @@ import { - SourceControlProviderKind, type PullRequestCheck, PullRequestComment, ThreadPullRequestWatch, @@ -40,8 +39,19 @@ const check = (name: string, status: PullRequestCheck["status"]): PullRequestChe type Detail = Parameters[1]; +const capabilities = (checksAlwaysReport: boolean): Detail["capabilities"] => ({ + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: false, + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + checksAlwaysReport, +}); + const detail = (overrides: Partial = {}): Detail => ({ - provider: SourceControlProviderKind.make("github"), + capabilities: capabilities(true), headSha: "aaaaaaaaaa", checks: [check("lint", "success"), check("test", "pending")], mergeability: "mergeable", @@ -273,6 +283,28 @@ describe("evaluatePullRequestWatch", () => { ]); }); + it("wakes once per edit of a remark seen at the watermark, never for the agent's own", () => { + const seen = remark("ci-bot", "2026-10-10T19:52:40.000Z"); + const watching = watch({ + headSha: "aaaaaaaaaa", + remarksThrough: seen.createdAt, + remarkIds: [seen.id], + wakes: 2, + }); + assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [seen]).changes, []); + const edited = { ...seen, editedAt: "2026-10-10T20:04:50.000Z" }; + const first = evaluatePullRequestWatch(watching, detail(), [edited]); + assert.deepEqual(first.changes, [{ kind: "remarks", remarks: [edited] }]); + assert.strictEqual(first.next.remarksThrough, edited.editedAt); + assert.deepEqual(evaluatePullRequestWatch(first.next, detail(), [edited]).changes, []); + const again = { ...seen, editedAt: "2026-10-10T20:05:50.000Z" }; + const second = evaluatePullRequestWatch(first.next, detail(), [again]); + assert.deepEqual(second.changes, [{ kind: "remarks", remarks: [again] }]); + assert.deepEqual(evaluatePullRequestWatch(second.next, detail(), [again]).changes, []); + const own = { ...edited, author: { login: "agent-user", name: null, avatarUrl: null } }; + assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [own]).changes, []); + }); + it("does not treat a failed check read as a rerun", () => { const failed = detail({ checks: [check("lint", "failure")] }); const reported = evaluatePullRequestWatch(watch(), failed, noRemarks); @@ -364,11 +396,8 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(pushed.flat(), ["no-checks"]); }); - it("never reports missing checks on a host whose checks it cannot read", () => { - const azure = detail({ - provider: SourceControlProviderKind.make("azure-devops"), - checks: [], - }); - assert.isTrue(anHourLater(watch(), azure).every((kinds) => kinds.length === 0)); + it("never reports missing checks on a host where an empty list says nothing", () => { + const silent = detail({ capabilities: capabilities(false), checks: [] }); + assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0)); }); }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 371cb46de8d1..810a899e1778 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -7,12 +7,9 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -/** - * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so - * this only stops a chatty bot looping an agent that is replying to it. - */ +/** Bounds consecutive comment-only wakes to stop bot reply loops; check, conflict, or push news resets it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; -/** How long a GitHub head may go without any check before the agent is told none reported. */ +/** How long a head may go without any check before the agent is told none reported. */ const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; @@ -30,7 +27,7 @@ export interface PullRequestWatchReport { readonly exhausted: boolean; } -// "action-required" is a finished check that needs someone, so the agent hears about it. +// action-required is a finished check needing human input. const isFailedCheck = (check: PullRequestCheck) => check.status === "failure" || check.status === "cancelled" || check.status === "action-required"; @@ -38,7 +35,7 @@ export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< PullRequestDetail, - "provider" | "headSha" | "checks" | "mergeability" | "viewer" | "author" + "capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author" >, remarks: ReadonlyArray | null, now: number, @@ -47,7 +44,7 @@ export function evaluatePullRequestWatch( const headSha = detail.headSha ?? null; const headMoved = headSha !== watch.headSha; - // An empty list keeps the last state: a host can answer with one when its check read fails. + // Hosts can return no checks on read failure; preserve prior state. let failedChecks = headMoved ? [] : watch.failedChecks; let passed = headMoved ? false : watch.passed; let passedChecks = headMoved ? [] : watch.passedChecks; @@ -63,8 +60,7 @@ export function evaluatePullRequestWatch( const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); const gateNames = gate.map((check) => check.name); const told = passed && passedChecks.length === 0 ? gateNames : passedChecks; - // A required job created and finished between two passes is never seen pending. Without - // required checks, any check counts, and advisory bots keep adding passed ones: no wake. + // Required checks can finish between sweeps; detect new names. With no required checks, ignore advisory gate growth. const gateGrew = required.length > 0 && gateNames.some((name) => !told.includes(name)); // Other checks still pending when required ones passed are owed one wake when all settle. // Only those are owed, so later advisory churn never wakes. One turned required is gate news. @@ -95,10 +91,10 @@ export function evaluatePullRequestWatch( // Only a head never read with checks waits for them: an empty read after checks were seen is // the failed read above. A watch saved before this field may have seen pending checks on its - // head, so its clock starts only at the next push. Other hosts can lack a checks API (Azure - // DevOps pipelines), where an empty list says nothing, so only GitHub waits. + // head, so its clock starts only at the next push. Where a host's empty list can mean the + // repository runs no checks at all, nothing waits. let checksAwaitedSince = watch.checksAwaitedSince; - if (detail.provider === "github") { + if (detail.capabilities.checksAlwaysReport === true) { const awaitedBefore = headMoved ? DateTime.formatIso(DateTime.makeUnsafe(now)) : (watch.checksAwaitedSince ?? null); @@ -113,7 +109,7 @@ export function evaluatePullRequestWatch( const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); const through = Date.parse(watch.remarksThrough); const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt; - // GitHub times are per second, so remarks at the boundary time are told apart by ID. + // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID. const fresh = (remarks ?? []).filter((remark) => { const at = Date.parse(activeAt(remark)); return ( @@ -133,7 +129,7 @@ export function evaluatePullRequestWatch( if (detail.mergeability === "conflicting" && !watch.conflicting) { changes.push({ kind: "conflicting" }); } - // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict. + // "unknown" is a host still computing after a push; only a clean answer clears a conflict. const conflicting = detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c4892bf7a466..e09670deb021 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -185,6 +185,7 @@ const watchedPullRequestDetail = (input: { actions: [], mergeMethods: [], search: false, + checksAlwaysReport: true, review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, reviewers: { request: false, listCandidates: false }, }, @@ -1232,6 +1233,136 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }), ); + it.effect("reports an answer that never reaches its provider without reopening it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("runtime-lost-answer"); + // Projected as attached, but no live session holds it, so every delivery fails. + const providerSessionId = ProviderSessionId.make("runtime-lost-answer-session"); + const requestId = RuntimeRequestId.make("runtime-lost-answer-request"); + const nodeId = NodeId.make("runtime-lost-answer-node"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-lost-answer-create"), + createdBy: "user", + creationSource: "web", + threadId, + projectId: ProjectId.make("runtime-lost-answer-project"), + title: "Lost answer", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* eventSink.write({ + commandId: CommandId.make("runtime-lost-answer-seed"), + events: [ + { + id: EventId.make("runtime-lost-answer-session-event"), + type: "provider-session.attached", + threadId, + driver, + occurredAt: now, + payload: { + id: providerSessionId, + driver, + providerInstanceId: modelSelection.instanceId, + status: "running", + cwd: process.cwd(), + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }, + { + id: EventId.make("runtime-lost-answer-node-event"), + type: "node.updated", + threadId, + nodeId, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId: nodeId, + kind: "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("runtime-lost-answer-request-event"), + type: "runtime-request.updated", + threadId, + nodeId, + occurredAt: now, + payload: { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId }, + createdAt: now, + resolvedAt: null, + }, + }, + ], + }); + const commandId = CommandId.make("runtime-lost-answer-answer"); + const answers = { color: "Blue" }; + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId, + threadId, + requestId, + answers, + }); + const errors = orchestrator + .getThreadProjection(threadId) + .pipe( + Effect.map((projection) => projection.turnItems.filter((item) => item.type === "error")), + ); + + // A delivery that can still be retried reports nothing. + yield* worker.drain(); + assert.deepEqual(yield* errors, []); + for (let attempt = 1; attempt < 5; attempt++) { + yield* TestClock.adjust("30 seconds"); + yield* worker.drain(); + } + + const [deliveryEffect] = yield* outbox.listByCommandId(commandId); + assert.equal(deliveryEffect?.request.type, "runtime-request.respond"); + assert.equal(deliveryEffect?.status, "failed"); + const failed = yield* orchestrator.getThreadProjection(threadId); + const reported = failed.turnItems.filter((item) => item.type === "error"); + assert.equal(reported.length, 1); + assert.equal(reported[0]?.status, "failed"); + assert.equal(reported[0]?.nodeId, nodeId); + // The answer stays recorded; the user is not asked again. + const request = failed.runtimeRequests.find((row) => row.id === requestId); + assert.equal(request?.status, "resolved"); + assert.deepEqual(request?.answers, answers); + }), + ); + it.effect("dismisses message-capable questions directly and while settling", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2118,6 +2249,25 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.map((link) => link.number), [2, 3, 4], ); + for (const [index, route] of ["pull", "pulls"].entries()) { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`gitcafe-pr-link-${index}`), + threadId, + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 21353 + index, + url: `https://git.cafe/pingdotgg/t3code/${route}/${21353 + index}`, + source: "manual", + }); + } + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests + ?.filter((link) => link.host === "git.cafe") + .map((link) => link.url), + [21353, 21354].map((number) => `https://git.cafe/pingdotgg/t3code/pulls/${number}`), + ); }), ); @@ -2605,12 +2755,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* reactor.sweep; assert.deepEqual(yield* summaries, ["#12: checks passed"]); - // A restart keeps nothing in memory; the watch state is all it has. const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); yield* restarted.sweep; assert.deepEqual(yield* summaries, ["#12: checks passed"]); - // A new watch on the same pull request knows what the last one told. yield* watch(false, "stop"); yield* watch(true, "again"); yield* restarted.sweep; @@ -2790,7 +2938,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const started = (yield* watchOf)?.watch; assert.isDefined(started); if (started === undefined) return; - // Green checks were reported, and one more comment-only wake ends the watch. yield* orchestrator.dispatch({ type: "thread.pull-request-watch.sync", commandId: CommandId.make("pr-watch-limit-told"), @@ -2850,7 +2997,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.isFalse(ended?.endedWatch?.passed); assert.deepEqual(yield* summaries, ["#13: new comments, stopped watching"]); - // The checks went pending after the last report, so passing again is news. status = "success"; yield* TestClock.adjust("10 minutes"); yield* watch("again"); @@ -3410,7 +3556,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { occurredAt: now, payload: { ...original, status: "cancelled", completedAt: now }, }, - // A provider wake the restart cut before it started does not supersede it. { id: EventId.make("restart-cut-wake-event"), type: "run.created", @@ -3479,10 +3624,11 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ["completed", "shell", false], ["waiting", "shell", false], ["cancelled", "shell", false], - ["failed", "monitor", false], - ["completed", "monitor", true], - ["waiting", "monitor", true], - ["cancelled", "monitor", true], + ["completed", "watchdog-monitor", false], + ["failed", "awaited-monitor", false], + ["completed", "awaited-monitor", true], + ["waiting", "awaited-monitor", true], + ["cancelled", "awaited-monitor", true], ] as const)( "admits a restart continuation of a %s run that lost a %s: %s", ([status, kind, admitted]) => @@ -3540,7 +3686,15 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { occurredAt: now, payload: { runId: original.id, - restartCancelledBackgroundWork: [{ kind, label: "sleep 25" }], + restartCancelledBackgroundWork: [ + kind === "shell" + ? { kind, label: "sleep 25" } + : { + kind: "monitor", + label: "sleep 25", + ...(kind === "awaited-monitor" ? { awaited: true as const } : {}), + }, + ], }, }, ], @@ -3900,8 +4054,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.isTrue((yield* maintenance.rebuild).valid); yield* assertCancelled; - // Preparation may finish after archive. Even reopening must not let its - // late callbacks revive the cancelled run or its background activity. + // Late preparation callbacks must not revive archived runs, even after reopen. yield* orchestrator.dispatch({ type: "thread.unarchive", commandId: CommandId.make("archive-preparing-unarchive"), @@ -4247,7 +4400,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("late-wake")).id); assert.equal(millis((yield* runFor("late-wake")).workStartedAt), millis(queuedStartedAt)); - // Runs sort in the order they started, including after a wake that ran ahead. const ordinals = yield* Effect.forEach( ["prompt", "early-wake", "queued", "late-wake"], (key) => Effect.map(runFor(key), (run) => run.ordinal), @@ -4861,21 +5013,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ); yield* outbox.enqueue([deadSessionRespond, otherSessionRespond]); - // Drive the run and its provider turn into "running" the same way a - // real provider session reaching that state would, without actually - // opening one in ProviderSessionManagerV2. No server restart happens - // between a run starting and its shared session later getting - // released on idle timeout, so by the time interrupt is dispatched - // the projection still says "running" while the live session is - // already gone: `sessions.get` for it returns none, exactly as it - // would after a real release. - // - // Also seed an in-flight turn item, a persistent dynamic_tool item, a - // streaming message, and a running subagent (linked to the child - // thread above) on the same run, mirroring the in-flight work a real - // provider turn would leave open. None of these are reported terminal - // by a live process, so interrupt finalization must settle them - // itself. + // Drive the run and its turn to "running" without a real session, as after an idle-timeout + // release: the projection says running while `sessions.get` returns none. Also seed in-flight + // items (tool, message, running subagent) that interrupt finalization must settle itself. const activeAttempt = (yield* orchestrator.getThreadProjection(threadId)).attempts.find( (attempt) => attempt.id === activeRun.activeAttemptId, )!; @@ -5141,7 +5281,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { runId: (yield* runFor("active")).id, holdQueue: true, }); - // The continuation starts while the older messages wait in the held queue. yield* dispatch("continue", true); yield* orchestrator.dispatch({ type: "queue.resume", @@ -5768,8 +5907,7 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { const firstSnoozedAt = firstProjection.thread.snoozedAt; assert.isNotNull(firstSnoozedAt); - // Repeating a calendar preset sends the same wake time. It must still - // restamp snoozedAt, or a failure that woke the thread keeps it awake. + // Restamp snoozedAt for identical wake times so previously waking failures do not keep the thread awake. yield* TestClock.adjust("1 second"); yield* orchestrator.dispatch({ type: "thread.snooze", @@ -5928,6 +6066,76 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { ); }); +it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("thread opens", (it) => { + it.effect( + "counts opens on the server clock, merges quick reopens, and keeps picked queries", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-opened-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-opened-thread-create"), + threadId, + projectId: ProjectId.make("runtime-layer-opened-project"), + title: "Opened thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + let visits = 0; + const visit = (input: { readonly opened?: boolean; readonly searchQuery?: string }) => + orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make(`runtime-layer-opened-visit-${++visits}`), + threadId, + // A client clock far in the past must not move the open time. + visitedAt: "2000-01-01T00:00:00.000Z", + ...input, + }); + const opens = Effect.map( + orchestrator.getShellSnapshot(), + (shell) => shell.threads.find((thread) => thread.id === threadId)?.opens ?? null, + ); + const start = DateTime.toEpochMillis(yield* DateTime.now); + + yield* visit({ opened: true, searchQuery: " Upl " }); + assert.deepEqual(yield* opens, { + openedAt: start, + score: 1, + picks: [{ query: "upl", at: start }], + }); + + yield* TestClock.adjust("1 minute"); + yield* visit({ opened: true, searchQuery: "Uploadthing docs" }); + assert.deepEqual(yield* opens, { + openedAt: start, + score: 1, + picks: [ + { query: "uploadthing docs", at: start + 60_000 }, + { query: "upl", at: start }, + ], + }); + + yield* TestClock.adjust("14 days"); + yield* visit({}); + yield* visit({ opened: true }); + const later = yield* opens; + assert.closeTo(later!.score, 1 + 2 ** (-(14 * 24 * 60 + 1) / (14 * 24 * 60)), 1e-9); + assert.equal(later!.openedAt, DateTime.toEpochMillis(yield* DateTime.now)); + // Opening without a query keeps each pick and its time. + assert.deepEqual(later!.picks, [ + { query: "uploadthing docs", at: start + 60_000 }, + { query: "upl", at: start }, + ]); + }), + ); +}); + it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", (it) => { it.effect("persists only the changed PR link and replays it without losing other links", () => Effect.gen(function* () { @@ -6031,8 +6239,6 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", (yield* orchestrator.getThreadShell(threadId))?.pullRequests, after?.pullRequests, ); - // Later syncs of the same link supersede the ones between the first - // (kept for replay order) and the newest. for (const [commandId, title] of [ ["compact-pr-sync-again", "Still open PR"], ["compact-pr-sync-merged", "Merged PR"], @@ -6072,9 +6278,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", latest?.pullRequests, ); - // A later visit is read state only: clients keep updatedAt from the - // rename, so compaction must keep the rename. Two visits supersede each - // other, and a later rename supersedes a visit. + // Visits preserve updatedAt, so compaction must retain earlier renames; later renames supersede visits. const threadEvents = () => sql<{ event_type: string }>` SELECT event_type FROM orchestration_events WHERE stream_id = ${threadId} @@ -6251,9 +6455,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { OrchestrationV2Run, OrchestrationV2Run, ]; - // A user stop holds the queue as the run ends (thread.turn.interrupt with - // holdQueue). Without the hold, the terminal-run worker may start the - // queued run before the resume below, depending on fiber scheduling. + // Hold the queue like user Stop so the terminal worker cannot start it before explicit resume. yield* events.write({ events: [ { @@ -6902,7 +7104,6 @@ it.layer(layerTest)("usage-limit recovery", (it) => { threadId, }); const resumed = yield* orchestrator.getThreadProjection(threadId); - // The held message ran after the continuation, so it now sorts after it. assert.deepEqual( resumed.runs.map((run) => run.id), [run.id, continuation.id, held.id], diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index ad4c7ecda878..8e1c65443ea3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,3 +1,4 @@ +import * as GuestShareService from "../guest/GuestShareService.ts"; import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as Layer from "effect/Layer"; @@ -222,8 +223,7 @@ const layerOrchestratorProvided = Orchestrator.layer.pipe( IdAllocator.layer, ProjectStore.layer, layerProviderAdapterRegistryProvided, - // Same layer reference as the continuation worker and the adapter - // infrastructure so layer memoization yields one shared request queue. + // Reuse the continuation worker's layer reference so memoization shares one request queue. ProviderContinuationRequests.layer, layerProviderEventIngestorProvided, layerRuntimePolicyProvided, @@ -367,6 +367,9 @@ export const layerProduction = Layer.mergeAll( layerThreadLifecycleProvided, layerScheduledTaskProvided, layerSecretRequestsProvided, + GuestShareService.layer.pipe( + Layer.provide(Layer.merge(layerOrchestratorProvided, layerCommandOutputHubProvided)), + ), UsageLimitRecoveryWorker.layer.pipe( Layer.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts b/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts index 7cdb030297b2..4ce58753a793 100644 --- a/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts +++ b/apps/server/src/orchestration-v2/testkit/CommandOutputRecorder.ts @@ -12,10 +12,8 @@ import * as CommandOutputHub from "../CommandOutputHub.ts"; import type * as EventSink from "../EventSink.ts"; import type * as ProjectionStore from "../ProjectionStore.ts"; -/** What one command streamed through the hub during a replay. */ export interface RecordedCommandOutput { readonly chunks: number; - /** The live tail as a viewer would have seen it. */ readonly output: TerminalOutputState; } @@ -24,7 +22,6 @@ export class CommandOutputRecorder extends Context.Service< { readonly snapshot: Effect.Effect> } >()("t3/orchestration-v2/testkit/CommandOutputRecorder") {} -/** The real hub, plus a record of every chunk it was given, keyed by turn item. */ export const layer: Layer.Layer< CommandOutputHub.CommandOutputHub | CommandOutputRecorder, never, diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index afd195d4e76d..2d441a74f319 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -91,12 +91,7 @@ const readClaudeSubagentResumeTranscript = Effect.fn("readClaudeSubagentResumeTr ); }, ); -/** - * The same session with the math.ts subagent's first run removed: the root - * turn settles while the subagent still works, and the server restarts - * before it reports, so recovery cancels it. The resumed turn then carries - * the restart note, as T3 sends it. - */ +/** Remove the subagent's first run so restart cancels it after root settlement; resume carries the restart note. */ const readClaudeCancelledSubagentResumeTranscript = Effect.fn( "readClaudeCancelledSubagentResumeTranscript", )(function* () { @@ -278,9 +273,7 @@ describe("orchestrator replay recovery", () => { () => Effect.scoped( Effect.gen(function* () { - // Checkpoint against a throwaway git workspace: with no override the - // scope cwd falls back to process.cwd(), and a cold full-repo - // baseline capture on CI outlives the scenario wait budget. + // Use a throwaway checkpoint workspace; cold full-repo capture exceeds CI wait budgets. const workspace = yield* checkpointWorkspace("provider_thread_resume"); const transcript = yield* readCodexTranscript(workspace); const fs = yield* FileSystem.FileSystem; @@ -567,8 +560,6 @@ describe("orchestrator replay recovery", () => { assert.equal(settled.subagents[0]?.status, "running"); assert.equal(childRootStatus(childThreadOf(before, settled.thread.id)), "running"); - // The server restarts with the subagent still working. No automatic - // continuation: the user's next message resumes it. const result = yield* Effect.scoped( runOrchestratorV2ProviderReplayScenario( { @@ -596,7 +587,6 @@ describe("orchestrator replay recovery", () => { assert.equal(projection.subagents[0]?.status, "completed"); const child = childThreadOf(result, projection.thread.id); assert.equal(child?.thread.id, projection.subagents[0]?.childThreadId); - // Recovery cancelled the child's work; the resume reopened and ended it. assert.equal(childRootStatus(child), "completed"); const conversation = (child?.turnItems ?? []) .toSorted((left, right) => left.ordinal - right.ordinal) diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 4e98e20949c9..0404496693fa 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -135,6 +135,8 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray ); const SCENARIO_WAIT_ATTEMPTS = 10_000; -// Iterations count event-loop turns, not time: while async work (git -// subprocesses, fixture IO) is in flight the loop is idle and the counter -// burns at full speed on slow runners. A wait is exhausted only once BOTH -// the iteration budget and this wall-clock deadline are spent. +// Iteration budgets burn through idle event-loop turns during async I/O; exhaust both iterations and wall-clock deadline. const SCENARIO_WAIT_DEADLINE_MS = 60_000; const scenarioWaitDeadline = () => performance.now() + SCENARIO_WAIT_DEADLINE_MS; const scenarioWaitExhausted = (attemptsRemaining: number, deadlineAt: number) => @@ -572,8 +571,7 @@ export function runOrchestratorV2Scenario( }); } while (true) { - // The run settles only through the latest armed debounce; a rearm (a - // late frame) supersedes it with a new receipt. + // Only the latest debounce receipt settles the run; late frames rearm it. const armed = yield* Effect.promise(() => gate.waitForFinishArmed(consumedFinishReceipts), ).pipe( @@ -728,8 +726,7 @@ export function runOrchestratorV2Scenario( }; }), ).pipe( - // A failed step can leave provider frames held at a gate. Release them - // before the harness shuts the provider down, or teardown waits on them. + // Release held provider frames before shutdown so teardown cannot block on gates. Effect.ensuring(Effect.sync(() => options.replayGate?.releaseAll())), ); } diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 0342a3ccc9d9..76f89af4847d 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -290,8 +290,7 @@ export function layerWithRegistry( Layer.provide(RuntimePolicy.layer), ); const layerDatabase = options.databaseLayer ?? SqlitePersistence.layerMemory; - // One queue shared by the adapters, the orchestrator, and the worker, like - // runtimeLayer.ts; layer memoization keeps it a single instance. + // Share one continuation queue across adapters, orchestrator, and worker through layer memoization. const layerContinuationRequests = options.runContinuationWorker === true ? ProviderContinuationRequests.layer : Layer.empty; const layerProvidedRegistry = registryLayer.pipe( @@ -353,7 +352,6 @@ export function layerWithRegistry( IdAllocator.layer, layerProviderEventIngestorProvided, ); - // The real hub, recording what each command streamed so fixtures can assert it. const layerCommandOutputHubProvided = CommandOutputRecorder.layer.pipe( Layer.provide(Layer.merge(layerEventSinkProvided, layerStores)), ); @@ -519,8 +517,7 @@ export function layerWithRegistry( if (options.runEffectWorker === false) { return layerReplayRuntime; } - // Built before the runtime it shares stores with, so recovery commits before - // the effect worker claims anything, as in serverRuntimeStartup. + // Build recovery before runtime so it commits before the effect worker claims work. const layerStartupRecovery: Layer.Layer< never, MigrationError | PlatformError.PlatformError | SqlError diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts index eea7b7e4ac06..c1c3d9a171b0 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts @@ -13,9 +13,7 @@ import { CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT } from "./input.ts"; const MONITOR_TASK_ID = "b1htjkjev"; -// Claude reports a Monitor as task_type local_bash, the same as a background -// Bash command. Only the Monitor tool call that started the task tells them -// apart, so the roster and the wake must name it a monitor. +// Claude reports Monitors as local_bash; only the launching tool call distinguishes them from background Bash. export function assertClaudeBackgroundMonitorWakeOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, @@ -35,8 +33,7 @@ export function assertClaudeBackgroundMonitorWakeOutput( event.type === "provider-thread.updated" ? (event.payload.pendingBackgroundTasks ?? []) : [], ) .filter((task) => task.taskId === MONITOR_TASK_ID); - // Claude's first roster snapshot lands one frame before the task_started - // that links the task to its Monitor call, so only that entry reads command. + // The first roster precedes task_started linking the Monitor call, so that entry reads command. assert.isAbove(rosterTasks.length, 1); for (const task of rosterTasks.slice(1)) { assert.deepEqual(task, { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/input.ts index c12efe23abbf..84b43c443f43 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/input.ts @@ -20,9 +20,7 @@ export const CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_RESUME_PROMPT = [ export const CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_FINAL_PROMPT = "Reply with exactly: ALL_DONE"; -// Each wake turn reports a notification that landed during the turn before -// it, so the wake result is what starts its continuation run. Each next prompt -// waits for that run to settle, as the recording waited for the wake turn. +// Wake results start continuations for prior-turn notifications; wait for settlement before the next prompt. export function claudeBackgroundSubagentLifecycleInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts index 79be3ee546d2..1c400be799d5 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts @@ -67,9 +67,7 @@ function sendMessageToolUseIds(transcript: ProviderReplayTranscript): ReadonlyAr }); } -// The recording itself is the evidence for how the CLI resumes a subagent: -// SendMessage re-emits task_started for the same task id under the -// SendMessage call's tool_use_id. +// SendMessage resumes reusing task ids, with task_started under its own tool_use_id. function assertRecordedResumeShape(transcript: ProviderReplayTranscript) { const agentAStarts = transcript.entries.flatMap((entry) => entry.type === "emit_inbound" && diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts index fc72111f2116..1caba03936c4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts @@ -12,8 +12,6 @@ import { CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT } from "./input.ts"; const BACKGROUND_TASK_ID = "bt3kw2qpn"; -// Interrupting a turn tears down its CLI process, which kills the background -// task it started. export function assertClaudeBackgroundTaskInterruptOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts index 38e166ac8844..279fbaefe0df 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts @@ -5,10 +5,7 @@ import { } from "../claude_background_subagent_lifecycle/input.ts"; import type { OrchestratorFixtureInput } from "../shared.ts"; -// Agent B's wait outlives the stop prompt, so its "stopped" notification -// lands during the stop turn and queues a wake turn in the CLI. The resume -// prompt is sent as soon as the stop turn settles, and the CLI runs the -// queued wake turn before it. +// The stopped notification arrives during the stop turn; its queued CLI wake precedes the immediately offered resume prompt. export const CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT = [ "Live-test background subagents. You must call the Agent tool before replying. Do exactly this, with no extra steps.", "", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/output.ts index fe4a6ed9deef..1e3e1bb80b8c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/output.ts @@ -21,9 +21,7 @@ function frameField(frame: unknown, key: string): unknown { return typeof frame === "object" && frame !== null ? Reflect.get(frame, key) : undefined; } -// The recording itself shows the ordering this fixture guards: the CLI ran a -// wake turn between the resume prompt's offer and that prompt's own turn, and -// only the prompt's turn echoed its uuid. +// A CLI wake can precede the offered prompt's turn; only the latter echoes its uuid. function assertRecordedWakeBeforePrompt(transcript: ProviderReplayTranscript) { const resumeOfferIndex = transcript.entries.findIndex( (entry) => entry.type === "expect_outbound" && entry.label === "prompt.offer:3", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn/input.ts index ec2d844f7899..686b5c337eda 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn/input.ts @@ -4,10 +4,7 @@ export const CLAUDE_COMPACT_FIRST_PROMPT = "Reply with exactly: compact probe fi // Held until the continuation run settles, as the CLI process outlives it. const CLAUDE_COMPACT_LAST_FRAME_LABEL = "command_lifecycle:compact-completed"; -// A `/compact` that Claude answers only after a turn of its own: a peer -// message woke it while the compaction was queued (the frames of a live -// `claude` 2.1.285 `/compact` with a peer-origin turn from a provider log -// spliced in front of it). +// Splice a peer-origin turn before live Claude 2.1.285 /compact frames to model queued compaction. export function claudeCompactAfterPeerTurnInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn_no_echo/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn_no_echo/output.ts index e6d88dc19901..e97f15895337 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn_no_echo/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_peer_turn_no_echo/output.ts @@ -6,10 +6,7 @@ import { assertSemanticProjectionIntegrity, projectionFor } from "../shared.ts"; import { CLAUDE_COMPACT_FIRST_PROMPT } from "../claude_compact_after_peer_turn/input.ts"; import { claudeCompactRuns } from "../claude_compact_after_peer_turn/output.ts"; -// A CLI before 2.1.252 neither echoes prompt uuids nor acknowledges them, so -// nothing says the peer turn is not `/compact`'s. Its result settles the -// `/compact` run, as it always has: the run must not wait for a result that -// may never come. +// Before CLI 2.1.252, no prompt echo or ACK distinguishes peer results; settle rather than wait for a missing result. export function assertClaudeCompactAfterPeerTurnNoEchoOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/output.ts index 4ce64eedcc0b..de38124e2cac 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_compact_after_resume_wake/output.ts @@ -6,11 +6,8 @@ import { assertSemanticProjectionIntegrity, projectionFor } from "../shared.ts"; import { CLAUDE_COMPACT_FIRST_PROMPT } from "../claude_compact_after_peer_turn/input.ts"; import { claudeCompactRuns } from "../claude_compact_after_peer_turn/output.ts"; -// The resumed process has not echoed yet, but it acknowledged the `/compact` -// prompt's uuid, so the wake's unechoed result is another turn's: it must not -// end `/compact` before its compaction. The wake reply streamed before the -// echo could place it, so it stays on the `/compact` run, as on a CLI that -// echoes only on the result. +// The acknowledged prompt uuid proves the unechoed wake result is foreign; it must not settle /compact. +// Already streamed wake text remains on the /compact run. export function assertClaudeCompactAfterResumeWakeOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/output.ts index d5e753b7acb8..bc257070a6da 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/output.ts @@ -13,9 +13,7 @@ import { const CHILD_TASK_ID = "a5d7d869cbb05c754"; const GRANDCHILD_TASK_ID = "a412f30077dfd84dd"; -// The grandchild ends while the root is idle, but Claude reports that end to -// the child that started it. Only the child's end wakes the root, so it alone -// opens run 2 and names the wake. +// Grandchild completion notifies its owning child; only that child's completion wakes the root. export function assertClaudeNestedBackgroundSubagentWakeOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/input.ts index de935e4caa85..7c57902a71cc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/input.ts @@ -4,12 +4,7 @@ import { type OrchestratorFixtureInput, } from "../shared.ts"; -/** - * Recorded from thread 47763f5e run 1 (2026-07-01): the SDK reports API-level - * failures (401 auth, 529 overloaded, …) as a result with subtype "success" but - * is_error: true, alongside a synthetic-model assistant message carrying the - * error text. - */ +/** SDK API failures use subtype "success", is_error true, and synthetic assistant error text. */ export function claudeResultIsErrorInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/output.ts index 7e353e00332c..96ba820de83d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/output.ts @@ -12,11 +12,6 @@ import { projectionFor, } from "../shared.ts"; -/** - * A command that prints for a few seconds streams its output live, and that - * output never becomes an orchestration event: the command item is persisted - * while running with no output, then once with its final output. - */ export function assertCommandOutputStreamingOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, @@ -34,31 +29,25 @@ export function assertCommandOutputStreamingOutput( assert.equal(command.status, "completed"); assert.include(command.output ?? "", "progress 100%"); - // Live output reached the hub, cleaned of escapes and the progress redraw. const streamed = result.commandOutput?.get(command.id); assert.isDefined(streamed, "the running command must stream its output"); assert.isAbove(streamed?.chunks ?? 0, 0); const liveText = streamed?.output.text ?? ""; const live = terminalOutputPlainText(liveText); - // Codex never streams what a command prints before its first yield (the recorded - // session has no delta, and no aggregatedOutput, for line 1), so it is not asserted. + // Codex omits pre-yield command output; the recording has no delta or aggregatedOutput for line 1. for (const line of ["stream line 2", "stream line 3", "stream line 4", "progress 100%"]) { assert.include(live, line); } - // The command's green survives as a canonical colour code; nothing else does. assert.include(liveText, "\u001b[0;32mstream line 2\u001b[0m"); assert.notInclude(live, "\u001b"); assert.notInclude(live, "progress 50%"); - // What viewers watched is the end of what the row keeps once it settles. const final = terminalOutputPlainText(normalizeTerminalOutput(command.output ?? "").text); assert.isTrue( final.trimEnd().endsWith(live.trimEnd()), `the live tail must agree with the persisted final output: ${JSON.stringify({ final, live })}`, ); - // The row shows what the command printed, not a serialized tool result. assert.notInclude(final, '"stdout"'); - // Never persisted while running: no event carries partial command output. const commandEvents = result.domainEvents.flatMap((event) => event.type === "turn-item.updated" && event.payload.id === command.id ? [event.payload] : [], ); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson index 43214e9a4e80..80ca85e01ba0 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson @@ -27,8 +27,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790383223057,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2700,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790383224553,"promptId":"t3-xai-prompt-1","streamStartMs":1790383223875,"turnStartMs":1790383223110,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2700,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790383224553,"promptId":"t3-xai-prompt-1","streamStartMs":1790383223875,"turnStartMs":1790383223110,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/input.ts index df3a16f69c7c..bdc2d5d3e84d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/input.ts @@ -1,10 +1,7 @@ import type { OrchestratorFixtureInput } from "../shared.ts"; export const GROK_AUTO_ROUTINE_MARKER = "grok-auto-routine-ok"; -/** - * Never exists, so approving the removal is harmless; `rm` of a non-scratch - * home path is one Grok's classifier always holds for the user. - */ +/** The path never exists; removing a non-scratch home path reliably triggers Grok approval. */ export const GROK_AUTO_BLOCKED_PATH = "grok-auto-probe-does-not-exist"; export const GROK_AUTO_PENDING_SHELL_KEY = "grok-auto-blocked-pending"; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/output.ts index 1c962bb32a9f..96db77dab7a7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/output.ts @@ -23,9 +23,7 @@ interface Frame { readonly result?: unknown; } -// Grok's Auto mode runs routine commands on its own classifier's say-so and -// asks about the ones it holds, but only a client declaring a prompting type -// gets asked; any other client gets a silent "Auto mode blocked this action". +// Grok Auto asks only clients declaring a prompting type; others silently receive "Auto mode blocked this action". export function assertGrokAutoBlockedCommandOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, @@ -82,8 +80,7 @@ export function assertGrokAutoBlockedCommandOutput( )?.frame.result; assert.deepEqual(answer, { outcome: { outcome: "selected", optionId: "allow-once" } }); - // Grok's bash prompt offers `always-allow`, which it saves for the whole - // project, not the session. The card must not offer it as a session choice. + // Grok saves always-allow project-wide; do not offer it as session approval. const approval = projection.turnItems.find( (item) => item.type === "approval_request" && item.requestId === request.id, ); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson index 92665d0833f8..b7158d6275a3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson @@ -27,8 +27,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790405167462,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:session_summary_generated","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_summary_generated","session_summary":"Background for-loop sleep echo tock command"}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"Background for-loop sleep echo tock command"}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/output.ts index b5b2cf847d81..79543b4591fa 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/output.ts @@ -12,9 +12,7 @@ import { } from "../shared.ts"; import { GROK_BACKGROUND_BASH_PROMPT, GROK_BACKGROUND_BASH_TICKS } from "./input.ts"; -// Grok completes a background shell command's tool call with a -// `BackgroundTaskStarted` acknowledgement while the process keeps running, -// streams its output afterwards, and ends it only with `x.ai/task_completed`. +// Grok ACKs background shells with BackgroundTaskStarted, then streams output until x.ai/task_completed. export function assertGrokBackgroundBashOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson index d0aa5f7a6f06..97c36f3e27e1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson @@ -30,8 +30,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790806313459,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-1","count":1},"_meta":{"agentTimestampMs":1790806313470}}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/server_status","frame":{"kind":"notification","method":"_x.ai/mcp/server_status","params":{"sessionId":"00000000-0000-4000-8000-000000000001","name":"uidotsh","source":"local","status":"ready","reason":"initialized","tools":null}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/init_progress","frame":{"kind":"notification","method":"_x.ai/mcp/init_progress","params":{"total":2,"connected":2,"sessionId":"00000000-0000-4000-8000-000000000001"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson index 8d983985a08b..92a295504b63 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790321078856,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2695,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790321080805,"promptId":"t3-xai-prompt-1","streamStartMs":1790321079590,"turnStartMs":1790321078886,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2695,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790321080805,"promptId":"t3-xai-prompt-1","streamStartMs":1790321079590,"turnStartMs":1790321078886,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/input.ts index 100638d8e25f..98f807dd38d9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/input.ts @@ -6,8 +6,7 @@ export const GROK_BACKGROUND_SUBAGENT_PROMPT = const GROK_SUBAGENT_WAKE_LABEL = "notification:session/update:agent_message_chunk:subagent-completed-00000000-0000-4000-8000-000000000002"; -// The root prompt settles while the subagent still runs, so run 1 is held open -// until `subagent_finished`, then finishes through the adapter's debounce. +// Hold run 1 until subagent_finished and the adapter's settlement debounce. export function grokBackgroundSubagentInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/output.ts index 713467a2152a..0a15fa9eb3dc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/output.ts @@ -20,9 +20,7 @@ function runAssistantTexts( ); } -// A background subagent outlives its root turn. Grok ends it only with the -// root-session `subagent_finished` notification: the spawn tool completed at -// launch and nothing names the subagent done in text. +// Only root-session subagent_finished ends background subagents; spawn results only acknowledge launch. export function assertGrokBackgroundSubagentOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson index 0229a0af335b..d513513fad0b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790319032095,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790319033828,"promptId":"t3-xai-prompt-1","streamStartMs":1790319032800,"turnStartMs":1790319032133,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790319033828,"promptId":"t3-xai-prompt-1","streamStartMs":1790319032800,"turnStartMs":1790319032133,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/output.ts index 285a8c40a12d..b775cb97f62f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/output.ts @@ -1,7 +1,9 @@ import { assert } from "@effect/vitest"; import type { ProviderReplayTranscript } from "@t3tools/contracts"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { cancelledTurnItemWork } from "../../../RestartBackgroundNote.ts"; import { assertBaseProjection, assertSemanticProjectionIntegrity, @@ -12,10 +14,8 @@ import { } from "../shared.ts"; import { GROK_MONITOR_PROMPT, GROK_MONITOR_TICKS } from "./input.ts"; -// Grok streams each monitor tick as an in_progress Bash result that already -// carries exit_code 0 and ends the monitor only through `_x.ai/task_completed`. -// Run 1 is held open until that end; Grok's own reply to the finished monitor -// then arrives as a continuation run, like Claude and Codex background wakes. +// Monitor ticks report in_progress with exit_code 0; only _x.ai/task_completed ends them. +// Hold run 1 until then; Grok's later reply is a continuation. export function assertGrokMonitorOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, @@ -54,6 +54,20 @@ export function assertGrokMonitorOutput( assert.equal(monitorUpdates[tickUpdate]?.status, "running", `${tick} must show running`); } + // A persistent monitor keeps running after its run ends. Background work and + // the restart note must then name it a monitor, as they do for Claude's Monitor. + const runningMonitor = monitorUpdates.find((update) => update.status === "running"); + assert.isDefined(runningMonitor); + assert.deepEqual( + derivePendingBackgroundWork({ + latestRun: rootRun, + providerThreads: [], + turnItems: [runningMonitor], + }).map((task) => task.kind), + ["monitor"], + ); + assert.equal(cancelledTurnItemWork(runningMonitor)?.kind, "monitor"); + const monitorCompletedAt = result.domainEvents.findIndex( (event) => event.type === "turn-item.updated" && diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson index d707290fcd7e..a63065d9a759 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson @@ -26,8 +26,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: grok prompt error fixture\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790805582748,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-1","count":1},"_meta":{"agentTimestampMs":1790805582754}}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/server_status","frame":{"kind":"notification","method":"_x.ai/mcp/server_status","params":{"sessionId":"00000000-0000-4000-8000-000000000001","name":"uidotsh","source":"local","status":"ready","reason":"initialized","tools":null}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/init_progress","frame":{"kind":"notification","method":"_x.ai/mcp/init_progress","params":{"total":2,"connected":2,"sessionId":"00000000-0000-4000-8000-000000000001"}}} @@ -41,8 +41,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","error":{"code":-32603,"message":"Internal error","data":{"message":"API error (status 400 Bad Request): invalid_request_error: grok prompt error fixture","http_status":400}}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-2","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790805583632,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-2","count":1},"_meta":{"agentTimestampMs":1790805583637}}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_execution","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_execution","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-2","runs":[{"name":"global/settings:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":422}}]},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790805584060}}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 75babf72ad7d..340f7c1470c3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -585,10 +585,7 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray\n\n\n\n\nRespond with exactly: steering fixture initial response\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235622,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"00000000-0000-4000-8000-000000000001","_meta":{"cancelTrigger":"ctrl_c"}}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:turn_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"turn_completed","prompt_id":"t3-xai-prompt-1","stop_reason":"cancelled","elapsed_ms":22},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-4","agentTimestampMs":1790300235647,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort"}}}} {"type":"emit_inbound","label":"notification:_x.ai/session/prompt_complete","frame":{"kind":"notification","method":"_x.ai/session/prompt_complete","params":{"sessionId":"00000000-0000-4000-8000-000000000001","promptId":"t3-xai-prompt-1","stopReason":"cancelled","agentResult":null,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"}}}} @@ -32,8 +32,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":2263,"modelId":"grok-4.7","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"},"cancelTrigger":"ctrl_c"}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Actually, respond with exactly: steering fixture observed"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235729,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-8","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_output.ts index 1ccd33eb00d1..a40d9f09bf5c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_output.ts @@ -20,11 +20,7 @@ function frameField(frame: unknown, key: string): unknown { return typeof frame === "object" && frame !== null ? Reflect.get(frame, key) : undefined; } -/** - * Pi steers through `prompt` with `streamingBehavior: "steer"`, which Pi - * queues into the running agent loop. The steer lands in the same provider - * turn: no interrupt, no second turn, one settled run. - */ +/** Pi queues prompt streamingBehavior "steer" into the running turn without interruption or a second turn. */ export function assertPiMessageSteeringOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson index 92c250e44b63..470e15d96342 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: first fixture turn complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790299188461,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:session_summary_generated","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_summary_generated","session_summary":"First fixture turn complete"}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"First fixture turn complete"}}}} @@ -57,8 +57,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":22212,"modelId":"grok-4.7","inputTokens":22168,"outputTokens":36,"cachedReadTokens":1664,"reasoningTokens":32,"usage":{"inputTokens":22168,"outputTokens":36,"totalTokens":22204,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":9575,"costUsdTicks":142990400,"modelUsage":{"grok-4.7-build":{"inputTokens":22168,"outputTokens":36,"totalTokens":22204,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":9575,"costUsdTicks":142990400}},"numTurns":1}}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790299198569,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-31","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-32","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":2}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-33","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":3}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson index fb6046b8533b..d16f57eef5ee 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444611408}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5},"last":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6b-707e-7001-b2f4-23db74e60097","clientName":"t3_code"},"emittedAtMs":1791444611412}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16},"last":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444611661}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","status":"accepted","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3"},"emittedAtMs":1791444611674}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37},"last":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37}},"item":{"itemId":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","kind":"reminderChild","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:11.790301Z","fallbackText":"Reminder child session","childSessionId":"c13379db-6761-40b4-a974-b0f1a58717aa","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-72ac-7972-a1df-b343b7804b38"}},"emittedAtMs":1791444611795}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.1","item":{"itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","kind":"agentMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444618068}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.2","itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","field":"text","delta":"first fixture turn complete"},"emittedAtMs":1791444618068}} @@ -29,12 +29,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"idle","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16"},"emittedAtMs":1791444621046}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79},"last":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","terminal":"completed","durationMs":9378,"timeToFirstTokenMs":6406},"emittedAtMs":1791444621047}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:17","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80},"last":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444621054}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","status":"accepted","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18"},"emittedAtMs":1791444621333}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106},"last":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106}},"item":{"itemId":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","kind":"reminderChild","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:21.446966Z","fallbackText":"Reminder child session","childSessionId":"cd97261a-3ea9-480f-9e54-5e59bb077da2","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-9869-7061-a4f3-42daa3e3a8f1"}},"emittedAtMs":1791444621455}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.1","item":{"itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","kind":"agentMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444623127}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.2","itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","field":"text","delta":"second fixture turn complete"},"emittedAtMs":1791444623127}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson index 8b425ec29268..12492cb13bb2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444728496}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5},"last":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a11a6d-39bb-7001-923e-425cc6129ce7","clientName":"t3_code"},"emittedAtMs":1791444728501}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16},"last":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444728769}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","status":"accepted","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3"},"emittedAtMs":1791444728783}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"},"emittedAtMs":1791444728783}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38},"last":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38}},"item":{"itemId":"29180105-84c4-4f9e-8bfd-003392b93b73","kind":"reminderChild","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:08.905989Z","fallbackText":"Reminder child session","childSessionId":"7c9485f9-26aa-4b56-8ca2-f36cb9cfb8c4","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-3c27-73f2-8c27-cd06a3b7dadf"}},"emittedAtMs":1791444728910}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32618,"outputTokens":315,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":227},"promptTokens":32618,"totalTokens":32933,"finishReason":"tool_calls","durationMs":2684,"cumulative":{"promptTokens":32618,"outputTokens":315,"totalTokens":32933,"cost":{"usd":0.0033248,"partial":false}}},"emittedAtMs":1791444731684}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:8","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"windowTokens":1007997,"usedTokens":32933,"pressure":"normal"},"emittedAtMs":1791444731684}} @@ -42,12 +42,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"idle","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23"},"emittedAtMs":1791444747386}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120},"last":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","terminal":"completed","durationMs":18608,"timeToFirstTokenMs":4791},"emittedAtMs":1791444747387}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:24","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121},"last":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444747394}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":5,"result":{"commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29","status":"accepted","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25"},"emittedAtMs":1791444747660}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:28","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148},"last":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148}},"item":{"itemId":"3b28955d-a87a-4acb-97e9-14cad87fd41f","kind":"reminderChild","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:27.787099Z","fallbackText":"Reminder child session","childSessionId":"efd21bf1-4fd1-49a2-9c55-91b7fc099765","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-85ed-7b91-8d91-c4ce76b6dfef"}},"emittedAtMs":1791444747794}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:29","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":33401,"outputTokens":201,"cachedTokens":33009,"cacheWriteTokens":0,"cacheReadTokens":33009,"reasoningTokens":108},"promptTokens":33401,"totalTokens":33602,"finishReason":"tool_calls","durationMs":2522,"cumulative":{"promptTokens":99045,"outputTokens":538,"totalTokens":99583,"cacheReadTokens":65506,"cost":{"usd":0.003592512,"partial":false}}},"emittedAtMs":1791444750395}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:30","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"windowTokens":1007997,"usedTokens":33602,"pressure":"normal"},"emittedAtMs":1791444750395}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/output.ts index 5e8b85162325..ac3d52d293c5 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/output.ts @@ -10,11 +10,7 @@ import { } from "../shared.ts"; import { MUSE_PERMISSION_APPROVED_FILE, MUSE_PERMISSION_DECLINED_FILE } from "./input.ts"; -/** - * Decline answers with Muse's Reject (`abort`) choice, so the command never - * runs and the turn still settles instead of waiting on an answer Muse cannot - * accept. - */ +/** Decline uses Muse Reject (abort); the command never runs and the turn can settle. */ export function assertMusePermissionOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson index c1462a92c2cc..dbbd70a9b596 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758279294}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5},"last":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a12d1d-a1b8-7000-8d6c-f1324b724cdb","clientName":"t3_code"},"emittedAtMs":1791758279297}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:2","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15},"last":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758279531}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","status":"accepted","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","status":"running","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3"},"emittedAtMs":1791758279541}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37},"last":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37}},"item":{"itemId":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","kind":"reminderChild","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:37:59.660065Z","fallbackText":"Reminder child session","childSessionId":"0779d605-54b8-45dc-90b6-91dc58b9a52c","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d1d-a3d4-7712-8b98-26f3c4f9bf38"}},"emittedAtMs":1791758279666}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.1","item":{"itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","kind":"reasoning","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791758282481}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.2","itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","field":"summary.0","delta":"Spawning a subagent via muse_subagent_spawn to run a shell command and return its exact output, while avoiding direct command execution."},"emittedAtMs":1791758282481}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/output.ts index 2e1edc685c3a..3b651581bf22 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/output.ts @@ -4,11 +4,7 @@ import type { ProviderReplayTranscript } from "@t3tools/contracts"; import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; import { assertSemanticProjectionIntegrity, projectionFor } from "../shared.ts"; -/** - * A native subagent's approval is asked on the parent thread, as Codex and - * OpenCode ask theirs: Muse children have no provider session of their own to - * answer from. - */ +/** Ask Muse child approvals on the parent; children have no provider sessions to answer through. */ export function assertMuseSubagentApprovalOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson index d73fa8b51bf5..f9c5f262fc58 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758989388}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5},"last":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d28-7788-7001-a223-c10e52187cdf","clientName":"t3_code"},"emittedAtMs":1791758989391}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:2","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15},"last":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758989619}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d28-7912-7000-993a-afd019e6ac5c","status":"accepted","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","status":"running","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3"},"emittedAtMs":1791758989632}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:6","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36},"last":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36}},"item":{"itemId":"44afb415-d6e4-4d51-9002-4deb196d463a","kind":"reminderChild","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:49:49.743418Z","fallbackText":"Reminder child session","childSessionId":"4dd3511e-2115-4fbb-a4c4-383cbb383d63","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d28-7997-79d0-8a69-8ac0acf92a56"}},"emittedAtMs":1791758989750}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:7","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32911,"outputTokens":264,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":143},"promptTokens":32911,"totalTokens":33175,"finishReason":"tool_calls","durationMs":3651,"cumulative":{"promptTokens":32911,"outputTokens":264,"totalTokens":33175,"cost":{"usd":0.0033439000000000003,"partial":false}}},"emittedAtMs":1791758993485}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:8","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"windowTokens":1007997,"usedTokens":33175,"pressure":"normal"},"emittedAtMs":1791758993485}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson index 714b315bf228..50f444b01d5f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson @@ -8,13 +8,13 @@ {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","path":"~/.local/share/muse/sessions/2026/10/11/01a12d5d-405f-7000-89a3-5aa62af6ea23/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T23:47:28.587276Z","updatedAt":"2026-10-11T23:47:28.587281Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:1","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5},"last":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d5d-4060-7000-b6cd-aaabe26f80de","clientName":"t3_code"},"emittedAtMs":1791762448807}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6},"last":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762448807}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:3","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16},"last":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762449097}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","status":"accepted","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","status":"running","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4"},"emittedAtMs":1791762449102}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38},"last":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38}},"item":{"itemId":"95574bfe-853f-44cc-81f6-88b05d6c8c16","kind":"reminderChild","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","recordedAt":"2026-10-11T23:47:29.328939Z","fallbackText":"Reminder child session","childSessionId":"b16dbb52-7f62-4a5a-84c2-f68d7ea22b18","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d5d-4375-7d72-b5b2-4b096d0df507"}},"emittedAtMs":1791762449336}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.1","item":{"itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","kind":"reasoning","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791762451554}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.2","itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","field":"summary.0","delta":"Launching a two-agent ping-pong workflow via the workflow tool while skipping skill reading for a trivial task."},"emittedAtMs":1791762451554}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts index 768c134efbc8..7d19c77aa40d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_compaction/output.ts @@ -13,11 +13,7 @@ import { projectionFor, } from "../shared.ts"; -/** - * `/compact` runs `session.compact`: OpenCode queues a compaction item and - * summarizes in its own execution, which ends the `/compact` turn. The next - * turn answers from the summary. - */ +/** session.compact queues a compaction execution that ends /compact; the next turn uses its summary. */ export function assertOpenCode2CompactionOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts index f2f03c22dd41..5ed2db1852a5 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_tool_call/output.ts @@ -21,9 +21,7 @@ export function assertOpenCode2ToolCallOutput( const projection = projectionFor(result, transcript.scenario); assertSemanticProjectionIntegrity(projection); assertVisibleTurnItemsMirrorLocalTurnItems(projection); - // Buffered delivery keeps streaming text out of the read model until - // `text.ended`, which OpenCode sends after both tool calls started. The text - // keeps the position it took when it began, ahead of the tool calls. + // OpenCode sends text.ended after tool starts; buffered text must retain its earlier position. assertTurnItemTypeSequence(projection, [ "user_message", "assistant_message", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/input.ts index 3ad9fbb02d0d..b2f10ceeada4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/input.ts @@ -5,11 +5,7 @@ const PI_COMPACTION_INSTRUCTED = `/compact keep the opaque marker ${PI_COMPACTIO const PI_COMPACTION_RECALL_PROMPT = "Return the opaque marker I asked you to remember. Respond with only the marker."; -/** - * The recording workspace sets a tiny `keepRecentTokens` so a short - * conversation is compactable, and the last turn proves the summary kept the - * marker. - */ +/** Tiny keepRecentTokens makes the short recording compactable. */ export function piCompactionInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/output.ts index 12f481f62e4e..3726fda9f931 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/output.ts @@ -72,8 +72,7 @@ export function assertPiCompactionOutput( ), "compaction must not run a model turn", ); - // Right after compacting, Pi reports context usage as unknown (tokens: null); - // the meter keeps Pi's own post-compaction estimate instead of going blank. + // Pi reports tokens null after compaction; retain its post-compaction estimate. const compactionTurn = projection.providerTurns.find( (turn) => turn.runAttemptId === compactionRuns[1]?.activeAttemptId, ); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts index 8b5f0e223533..50625a832ce2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/input.ts @@ -4,12 +4,7 @@ import { type OrchestratorFixtureInput, } from "../shared.ts"; -/** - * The idle gap outlasts ProviderSessionManager's 30-minute idle timeout, which - * releases the provider session. The second message therefore starts a fresh - * provider process that must resume the recorded native thread, and its prompt - * only succeeds if the resumed thread kept the first turn's history. - */ +/** Exceed the 30-minute idle timeout to force a fresh process; the second prompt requires resumed history. */ export function providerThreadResumeInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson index c8937afa2753..e1343593df51 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: first fixture turn complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300065163,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2641,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790300066681,"promptId":"t3-xai-prompt-1","streamStartMs":1790300065806,"turnStartMs":1790300065197,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2641,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790300066681,"promptId":"t3-xai-prompt-1","streamStartMs":1790300065806,"turnStartMs":1790300065197,"updateType":"AgentThoughtChunk","chunkId":2}}}} @@ -54,8 +54,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":22196,"modelId":"grok-4.7","inputTokens":22152,"outputTokens":36,"cachedReadTokens":1664,"reasoningTokens":32,"usage":{"inputTokens":22152,"outputTokens":36,"totalTokens":22188,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":1589,"costUsdTicks":142881600,"modelUsage":{"grok-4.7-build":{"inputTokens":22152,"outputTokens":36,"totalTokens":22188,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":1589,"costUsdTicks":142881600}},"numTurns":1}}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300067131,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-31","agentTimestampMs":1790300068257,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-32","agentTimestampMs":1790300068257,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":2}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-33","agentTimestampMs":1790300068258,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":3}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 5fd4d3590cc6..3d76845fa713 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -101,7 +101,6 @@ export const TURN_INTERRUPT_PROMPT = "Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."; export const TURN_INTERRUPT_MID_TOOL_PROMPT = "Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."; -/** A command that prints over a few seconds, with colour and a carriage-return redraw. */ const COMMAND_OUTPUT_STREAMING_COMMAND = "for i in 1 2 3 4; do printf '\\033[32mstream line %s\\033[0m\\n' $i; sleep 1; done; printf 'progress 50%%\\rprogress 100%%\\n'"; export const COMMAND_OUTPUT_STREAMING_PROMPT = `Run this exact shell command in the foreground and wait for it to finish: \`${COMMAND_OUTPUT_STREAMING_COMMAND}\`. Then respond exactly: command output fixture complete`; @@ -228,10 +227,7 @@ export type OrchestratorFixtureInputStep = readonly waitForTurnItemType?: OrchestrationV2TurnItem["type"]; } | { - /** - * A run held open for background work finishes through the adapter's - * debounce, which replay passes on the adapter's receipt. - */ + /** Replay finishes background-held runs through the adapter's debounce receipt. */ readonly type: "finish_held_run"; readonly targetRunIndex: number; readonly status: OrchestrationV2RunStatus; @@ -295,12 +291,7 @@ export type OrchestratorFixtureInputStep = export interface OrchestratorFixtureInput { readonly interactionMode?: ProviderInteractionMode; readonly runtimeMode?: RuntimeMode; - /** - * Files committed into the replay workspace before the scenario runs, keyed - * by workspace-relative path. A recorder must seed the same files so adapter - * logic that reads the workspace (e.g. skill discovery) sees what the - * provider saw. - */ + /** Files committed before replay, keyed by relative path; seed the same files when recording so workspace reads match. */ readonly workspaceFiles?: Readonly>; readonly steps: ReadonlyArray; } @@ -556,8 +547,7 @@ export function materializeFixtureInput(input: { }), ); - // A run that asks several times stays busy between answers, so the next - // answer waits for its request instead of for the thread to go idle. + // Multiple requests keep runs busy between answers; wait for each request, not thread idle. const answersNext = (stepIndex: number) => { const next = input.fixtureInput.steps[stepIndex + 1]?.type; return next === "approve_next_runtime_request" || next === "answer_next_user_input_request"; @@ -576,8 +566,7 @@ export function materializeFixtureInput(input: { (nextStep.type === "restart" && nextStep.targetRunIndex === runIndex) || (nextStep.type === "release_replay_gate_after_waiting" && nextStep.targetRunIndex === runIndex) || - // A provider continuation run starts while this thread is - // busy, so waiting for idle first would never return. + // Continuations start while busy; waiting for idle would deadlock. (nextStep.type === "await_run_status" && nextStep.targetRunIndex > runIndex) || // Held open until the test clock moves, so it cannot go idle first. nextStep.type === "finish_held_run")) || diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson index cdc7f802140d..463c61b43fd1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with the following text: fixture simple ok\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790301055553,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2716,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790301057223,"promptId":"t3-xai-prompt-1","streamStartMs":1790301056936,"turnStartMs":1790301055613,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2716,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790301057223,"promptId":"t3-xai-prompt-1","streamStartMs":1790301056936,"turnStartMs":1790301055613,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson index 8890613a9d77..3d402db32193 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444565334}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5},"last":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6a-bbb6-7000-b1a4-a01ea59676ef","clientName":"t3_code"},"emittedAtMs":1791444565340}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16},"last":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444565612}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","status":"accepted","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","status":"running","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3"},"emittedAtMs":1791444565635}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37},"last":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37}},"item":{"itemId":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","kind":"reminderChild","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:29:25.781511Z","fallbackText":"Reminder child session","childSessionId":"9785bf9d-382d-4781-ab3b-3817c9e6a604","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6a-bef1-7032-974a-059b8765c487"}},"emittedAtMs":1791444565789}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.1","item":{"itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","kind":"agentMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444567707}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.2","itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","field":"text","delta":"fixture simple ok"},"emittedAtMs":1791444567707}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_output.ts index e40510cdb569..c7579914964e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_output.ts @@ -24,11 +24,7 @@ function lastPiResponseData(transcript: ProviderReplayTranscript, command: strin return field(frame, "data"); } -/** - * Pi reports usage only through `get_session_stats`, read once per settled - * turn: each completed provider turn carries the stats response recorded for - * it, in order, not the last live streaming total. - */ +/** Settled Pi usage uses each recorded get_session_stats response in order, not the last streaming total. */ export function assertPiSettledTokenUsage( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, @@ -80,8 +76,7 @@ export function assertPiSimpleOutput( assertPiSettledTokenUsage(result, transcript); const [turn] = projection.providerTurns; - // Pi attaches cumulative usage to every streaming update, zeros until the - // provider reports. + // Pi streams cumulative usage, initially zeros until the provider reports. const streamedTotals = transcript.entries.flatMap((entry) => entry.type === "emit_inbound" && field(entry.frame, "type") === "message_update" ? [field(field(entry.frame, "usage"), "totalTokens")] diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_output.ts index 8d63647cf048..1174bd63396d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_output.ts @@ -9,11 +9,7 @@ import { SUBAGENT_V2_PROMPT, } from "../shared.ts"; -/** - * A Muse native subagent opens a child thread with its prompt and result. Muse - * cannot address the child's own session, so the thread shows nothing else and - * the result appears exactly once. - */ +/** Muse cannot address child sessions; their threads contain only the prompt and one result. */ export function assertMuseSubagentV2Output( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson index 965d0f17acdf..8e98fccc55e4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791757874894}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5},"last":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d17-75fa-7001-a940-79b5d7c648a4","clientName":"t3_code"},"emittedAtMs":1791757874898}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:2","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15},"last":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791757875120}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d17-7790-7000-967d-3f2246f38a60","status":"accepted","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","status":"running","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3"},"emittedAtMs":1791757875131}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:6","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36},"last":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36}},"item":{"itemId":"599a220a-d212-4a81-a60d-7284ff290284","kind":"reminderChild","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:31:15.249949Z","fallbackText":"Reminder child session","childSessionId":"0b3f4f01-850a-43bd-af36-9b011699354f","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d17-7819-7212-8057-3558c2bdd46d"}},"emittedAtMs":1791757875258}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:7","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32900,"outputTokens":129,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":36},"promptTokens":32900,"totalTokens":33029,"finishReason":"tool_calls","durationMs":2354,"cumulative":{"promptTokens":32900,"outputTokens":129,"totalTokens":33029,"cost":{"usd":0.0033158000000000003,"partial":false}}},"emittedAtMs":1791757877764}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:8","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"windowTokens":1007997,"usedTokens":33029,"pressure":"normal"},"emittedAtMs":1791757877764}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts index 26181c19c657..8e5ccea09119 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts @@ -11,12 +11,8 @@ import { } from "../shared.ts"; import { SUBAGENT_APPROVAL_PENDING_SHELL_KEY } from "./input.ts"; -/** - * A multi-agent v2 child inherits the root's approval policy, and Codex asks - * for its command approval on the child's own native thread and turn. The - * request is asked on the parent thread and run instead, under the subagent, - * because native subagent threads are hidden from the sidebar. - */ +/** Codex v2 children inherit approvals and request on their own native turns; surface requests under + * the subagent on the parent because native child threads are hidden. */ export function assertSubagentV2ApprovalOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_output.ts index 525b9b4cff4e..4cbc759eb310 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_output.ts @@ -4,10 +4,7 @@ import type { ProviderReplayTranscript } from "@t3tools/contracts"; import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; import { assertThreadRollbackOutput } from "../thread_rollback/codex_output.ts"; -/** - * Codex only reverts threads loaded in its current app-server process, so the - * released session's successor must resume the thread before `thread/revert`. - */ +/** Codex thread/revert requires a loaded thread; successor sessions must resume it first. */ export function assertThreadRollbackAfterRestartOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts index d98012a84aa3..7452f72bfbf5 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/input.ts @@ -5,10 +5,7 @@ import { type OrchestratorFixtureInput, } from "../shared.ts"; -/** - * The idle gap outlasts ProviderSessionManager's 30-minute idle timeout, which - * releases the provider session, as a server restart would. - */ +/** Exceed the 30-minute idle timeout to release the session as a restart would. */ export function threadRollbackAfterRestartInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts index c013a01c2dbc..d31d186c56f1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/input.ts @@ -6,10 +6,7 @@ import { type OrchestratorFixtureInput, } from "../shared.ts"; -/** - * Stop restarts the provider runtime, so the stopped turn's native rollback - * point must be captured before the process goes away. - */ +/** Stop restarts the runtime; capture native rollback points before process exit. */ export function threadRollbackAfterStopInput(): OrchestratorFixtureInput { return { steps: [ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_output.ts index af408d91d2ce..909fd32ff54e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_output.ts @@ -25,10 +25,7 @@ function outboundOfType(transcript: ProviderReplayTranscript, type: string) { ); } -/** - * Stop terminates Pi (Stop-with-restart), so the stopped turn's session-tree - * user entry has to be read before the kill. - */ +/** Stop kills Pi; read the stopped turn's session-tree user entry first. */ export function assertPiThreadRollbackAfterStopOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson index 0ca06d35889b..61770a4ef9c9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790329153513,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790329155080,"promptId":"t3-xai-prompt-1","streamStartMs":1790329154153,"turnStartMs":1790329153545,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790329155080,"promptId":"t3-xai-prompt-1","streamStartMs":1790329154153,"turnStartMs":1790329153545,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson index 81590cc061e7..b715de016e7d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790329142568,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2691,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790329143906,"promptId":"t3-xai-prompt-1","streamStartMs":1790329143232,"turnStartMs":1790329142589,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2691,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790329143906,"promptId":"t3-xai-prompt-1","streamStartMs":1790329143232,"turnStartMs":1790329142589,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/output.ts index 173d79d491ce..9ae817306484 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/output.ts @@ -53,8 +53,7 @@ export function assertToolCallReadOnlyOnRequestOutput( writes.some((item) => item.status === "completed"), "the approved write must complete", ); - // Grok's write reports an ACP v1 diff ({ path, oldText, newText }); the - // file_change must still carry it, like the v2 patch form. + // Grok returns ACP v1 diffs ({ path, oldText, newText }); retain them in file_change like v2 patches. for (const item of writes) { const content = writtenContent(item); assert.isDefined(content, `the approved ${item.type} must carry what it wrote`); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson index 100804ae950c..5cdbc179a518 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson @@ -23,6 +23,6 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300234968,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"runtime_exit","status":"cancelled"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson index 0d72ecd0dcb4..b23d87e93614 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson @@ -7,14 +7,14 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444725265}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5},"last":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6d-2d3e-7000-b0e7-f04c53ee070b","clientName":"t3_code"},"emittedAtMs":1791444725268}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16},"last":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444725509}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","startedNewTurn":true,"disposition":"started"}}} {"type":"expect_outbound","label":"turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"method":"turn/interrupt","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","status":"running","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3"},"emittedAtMs":1791444725522}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:05.638142Z","fallbackText":"Reminder child session","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725646}} {"type":"emit_inbound","label":"response:turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"3962d8d1-4608-4dda-8fdc-39a1804474aa","sequence":50}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"cancelled","recordedAt":"2026-10-08T07:32:05.727236Z","fallbackText":"Reminder child session","failureReason":"main run cancelled","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725734}} diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 4055fd85b7ae..38ad3b5c7de1 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -106,27 +106,6 @@ function interruptRow( }; } -function assistantRow( - text: string, - visibility: "local" | "inherited" = "local", -): OrchestrationV2ProjectedTurnItem { - const row = makeRow(0); - const sourceThreadId = visibility === "local" ? THREAD : ThreadId.make("parent-thread"); - return { - ...row, - visibility, - sourceThreadId, - item: { - ...row.item, - threadId: sourceThreadId, - type: "assistant_message", - messageId: MessageId.make("answer-0"), - text, - streaming: false, - }, - } as OrchestrationV2ProjectedTurnItem; -} - function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { return { thread: { @@ -176,6 +155,65 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { + it("keeps a human turn whole up to the turn row budget and pages a longer one from inside", () => { + const humanTurn = (length: number) => { + const items = Array.from({ length }, (_, index) => makeRow(index)); + items[0] = { + ...items[0]!, + item: { + ...items[0]!.item, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make("long-human-turn"), + text: "Continue", + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; + return items; + }; + + const whole = selectRecentTimelineWindow({ items: humanTurn(180), snapshotSequence: 1 }); + expect(whole.items).toHaveLength(180); + expect(whole.hasMoreHistory).toBe(false); + + const items = humanTurn(THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 100); + const recent = selectRecentTimelineWindow({ items, snapshotSequence: 1 }); + expect(recent.items).toHaveLength(THREAD_HISTORY_PAGE_POLICY.maxItems); + const loaded = [...recent.items]; + let cursor = recent.nextCursor; + for (let pages = 0; cursor !== null; pages += 1) { + expect(pages).toBeLessThan(items.length); + const page = selectHistoryPageFromCursor({ items, cursor, snapshotSequence: 1 }); + expect(page.items.length).toBeGreaterThan(0); + expect(page.items.length).toBeLessThanOrEqual( + page.items[0]?.item.type === "user_message" + ? THREAD_HISTORY_PAGE_POLICY.maxTurnItems + : THREAD_HISTORY_PAGE_POLICY.maxItems, + ); + loaded.unshift(...page.items); + cursor = page.nextCursor; + } + expect(loaded.map((row) => row.sourceItemId)).toEqual(items.map((row) => row.sourceItemId)); + + const large = items + .slice(0, 12) + .map((row) => + row.item.type === "command_execution" + ? { ...row, item: { ...row.item, output: "x".repeat(200_000) } } + : row, + ); + const bounded = buildBoundedThreadProjection({ + projection: makeProjection(large), + snapshotSequence: 1, + }); + expect(bounded.projection.visibleTurnItems.length).toBeLessThan(large.length); + expect(boundedTimelineEncodedBytes(bounded.projection)).toBeLessThanOrEqual( + THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + ); + }); + it("keeps background turns with user turns, with main's 150-turn fan-out ceiling", () => { const items = Array.from({ length: 161 }, (_, turn) => { const row = makeRow(turn * 2); @@ -195,13 +233,15 @@ describe("threadHistoryPaging", () => { }; return [prompt, makeRow(turn * 2 + 1)]; }).flat(); - const first = selectRecentTimelineWindow({ items, snapshotSequence: 1 }); + const policy = { ...THREAD_HISTORY_PAGE_POLICY, maxItems: 1_000, maxEncodedBytes: 10_000_000 }; + const first = selectRecentTimelineWindow({ items, snapshotSequence: 1, policy }); expect(first.items).toHaveLength(300); expect(first.items[0]?.sourceItemId).toBe("item-22"); const older = selectHistoryPageFromCursor({ items, cursor: first.nextCursor!, snapshotSequence: 1, + policy, }); expect(older.items).toHaveLength(22); expect(older.hasMoreHistory).toBe(false); @@ -244,13 +284,20 @@ describe("threadHistoryPaging", () => { inRun(run * 4 + 2, run, notification(`Steer ${run}`)), inRun(run * 4 + 3, run), ]).flat(); - const first = selectRecentTimelineWindow({ items, snapshotSequence: 1 }); + const policy = { + ...THREAD_HISTORY_PAGE_POLICY, + maxItems: 1_000, + maxTurnItems: 1_000, + maxEncodedBytes: 10_000_000, + }; + const first = selectRecentTimelineWindow({ items, snapshotSequence: 1, policy }); expect(first.items).toHaveLength(150 * 4 - 1); expect(first.items[0]?.sourceItemId).toBe(`item-${11 * 4 + 1}`); const older = selectHistoryPageFromCursor({ items, cursor: first.nextCursor!, snapshotSequence: 1, + policy, }); expect([...older.items, ...first.items].map((row) => row.sourceItemId)).toEqual( items.map((row) => row.sourceItemId), @@ -288,26 +335,46 @@ describe("threadHistoryPaging", () => { }); it("always includes at least one pathological oversized item", () => { - const items = [makeRow(0, { outputBytes: 2_000_000 }), makeRow(1, { outputBytes: 10 })]; - const page = selectRecentTimelineWindow({ - items, - snapshotSequence: 1, - policy: { maxItems: 50, maxEncodedBytes: 1_024 }, - }); - expect(page.items).toHaveLength(1); - expect(page.items[0]?.sourceItemId).toBe("item-1"); - expect(page.hasMoreHistory).toBe(true); - - const older = selectHistoryPageFromCursor({ - items, - cursor: page.nextCursor!, - snapshotSequence: 1, - policy: { maxItems: 50, maxEncodedBytes: 1_024 }, - }); - expect(older.items).toHaveLength(1); - expect(older.items[0]?.sourceItemId).toBe("item-0"); - expect(older.hasMoreHistory).toBe(false); - expect(older.nextCursor).toBeNull(); + for (const humanTurn of [false, true]) { + const oversized = makeRow(0, { outputBytes: 2_000_000 }); + const items = [ + humanTurn + ? ({ + ...oversized, + item: { + ...oversized.item, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make("oversized-human-turn"), + text: "x".repeat(2_000_000), + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem) + : oversized, + makeRow(1, { outputBytes: 10 }), + ]; + const policy = { ...THREAD_HISTORY_PAGE_POLICY, maxItems: 50, maxEncodedBytes: 1_024 }; + const page = selectRecentTimelineWindow({ items, snapshotSequence: 1, policy }); + expect(page.items).toHaveLength(1); + expect(page.items[0]?.sourceItemId).toBe("item-1"); + expect(page.hasMoreHistory).toBe(true); + + const older = selectHistoryPageFromCursor({ + items, + cursor: page.nextCursor!, + snapshotSequence: 1, + policy, + }); + expect(older.items).toHaveLength(1); + expect(older.items[0]?.sourceItemId).toBe("item-0"); + expect(older.hasMoreHistory).toBe(false); + expect(older.nextCursor).toBeNull(); + expect([...older.items, ...page.items].map((row) => row.sourceItemId)).toEqual( + items.map((row) => row.sourceItemId), + ); + } }); it("recovers identity-miss cursors when position is past a shrunken timeline", () => { @@ -557,7 +624,6 @@ describe("threadHistoryPaging", () => { const serializedBytes = Buffer.byteLength(JSON.stringify(bounded.projection), "utf8"); expect(serializedBytes).toBeLessThanOrEqual(THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes); - expect(bounded.payloadBudgetExceeded).toBe(false); expect(bounded.projection.plans).toEqual([]); expect(bounded.projection.contextHandoffs).toEqual([]); expect(populated.plans).toHaveLength(120); @@ -566,7 +632,7 @@ describe("threadHistoryPaging", () => { ).toBe(2_097_152); }); - it("preserves oversized actionable state and reports the budget exception", () => { + it("preserves oversized actionable state", () => { const projection = makeProjection([makeRow(0)]); const actionable = { ...projection, @@ -585,30 +651,10 @@ describe("threadHistoryPaging", () => { const bounded = buildBoundedThreadProjection({ projection: actionable, snapshotSequence: 9 }); - expect(bounded.payloadBudgetExceeded).toBe(true); expect(bounded.projection.plans[0]).toEqual(actionable.plans[0]); expect(bounded.projection.visibleTurnItems).toEqual(actionable.visibleTurnItems); }); - it("flips overflow at the exact encoded size when timeline text fits the budget", () => { - // Inherited rows are encoded once, local rows twice; neither may overflow early. - for (const visibility of ["local", "inherited"] as const) { - const projection = makeProjection([assistantRow("a".repeat(20_000), visibility)]); - const build = (maxEncodedBytes: number) => - buildBoundedThreadProjection({ - projection, - snapshotSequence: 1, - policy: { maxItems: 75, maxEncodedBytes }, - }); - const bytes = Buffer.byteLength( - JSON.stringify(build(Number.MAX_SAFE_INTEGER).projection), - "utf8", - ); - expect(build(bytes).payloadBudgetExceeded).toBe(false); - expect(build(bytes - 1).payloadBudgetExceeded).toBe(true); - } - }); - it("keeps agent-only turns visible when checkpoint file lists fill the byte budget", () => { const items = Array.from({ length: 3 }, (_, turn) => { const first = makeRow(turn * 5); @@ -641,7 +687,6 @@ describe("threadHistoryPaging", () => { expect(bounded.projection.visibleTurnItems).toHaveLength(items.length); expect(bounded.hasMoreHistory).toBe(false); - expect(bounded.payloadBudgetExceeded).toBe(true); }); it("keeps paged historical plan detail in the turn item and only status in its artifact", () => { @@ -685,6 +730,5 @@ describe("threadHistoryPaging", () => { markdown: "", detailInTurnItem: true, }); - expect(bounded.payloadBudgetExceeded).toBe(false); }); }); diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index e0aa215f3f1e..579639ca83ab 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -7,12 +7,15 @@ import type { } from "@t3tools/contracts"; /** - * Item/byte budgets only apply to histories without turn starts; tool activity - * must not split an ordinary conversation turn. + * Every window stops at the byte budget. A window that holds a user turn also + * stops at `maxUserTurns` or `maxTurnItems` rows, which keeps ordinary turns + * whole (V1 kept 500 activities). Any other window, such as an agent-driven + * thread or the inside of one very long turn, stops at `maxItems` rows. */ export const THREAD_HISTORY_PAGE_POLICY = { maxUserTurns: 10, maxItems: 75, + maxTurnItems: 500, maxEncodedBytes: 1_048_576, } as const; @@ -24,12 +27,16 @@ export function isConversationHistoryItem(item: Pick { - if ("text" in item) { - length += item.text.length; - } - return length > budget; - }; - return ( - projection.visibleTurnItems.some((row) => exceeds(row.item)) || - projection.turnItems.some(exceeds) - ); -} - export function projectedRowEncodedBytes(row: HistoryRow): number { return bytesOfJson(row); } -/** - * Bounded-snapshot cost: local rows also land in `projection.turnItems`, so - * charge the nested item a second time. Inherited rows only appear in the - * visible list. - */ +/** Charge local items twice because bounded snapshots also include projection.turnItems; inherited rows appear only once. */ export function projectedRowBoundedSnapshotEncodedBytes( row: OrchestrationV2ProjectedTurnItem, threadId: ThreadId | string, @@ -186,11 +167,7 @@ function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean { ); } -/** - * A wake (PR watch, task completion) starts its run with a notification instead of a user message, so a - * notification starts a turn when it is its run's first input. ProjectionStore's `run_starts` window query - * applies the same rule. - */ +/** Notifications can start wake turns when they are the run's first input; match ProjectionStore run_starts. */ export function threadHistoryTurnStarts( items: ReadonlyArray, ): ReadonlyArray { @@ -210,8 +187,7 @@ export function isThreadHistoryUserTurn(item: HistoryRow["item"]): boolean { } /** - * Histories without turn starts use row budgets and always admit at least one - * row so oversized items cannot deadlock paging. + * Always admit at least one row so oversized items cannot deadlock paging. */ function selectOlderTimelinePage(input: { readonly items: ReadonlyArray; @@ -236,14 +212,17 @@ function selectOlderTimelinePage(input: { const turnLimit = candidates.some((row) => isThreadHistoryUserTurn(row.item)) ? policy.maxUserTurns : undefined; + const maxItems = + turnLimit === undefined ? policy.maxItems : (policy.maxTurnItems ?? policy.maxItems); for (let index = end - 1; index >= 0; index -= 1) { const row = input.items[index]!; - const rowBytes = turnLimit === undefined ? rowCost(row) : 0; + const rowBytes = rowCost(row); if ( selected.length > 0 && - (turnLimit === undefined - ? selected.length >= policy.maxItems || encodedBytes + rowBytes > policy.maxEncodedBytes - : userTurns >= turnLimit || rawTurns >= THREAD_HISTORY_MAX_RAW_TURNS) + (selected.length >= maxItems || + encodedBytes + rowBytes > policy.maxEncodedBytes || + (turnLimit !== undefined && + (userTurns >= turnLimit || rawTurns >= THREAD_HISTORY_MAX_RAW_TURNS))) ) { break; } @@ -252,6 +231,10 @@ function selectOlderTimelinePage(input: { if (isThreadHistoryUserTurn(row.item)) userTurns += 1; if (turnStarts[index]) rawTurns += 1; } + // Inside a turn longer than the window, no selected row starts a user turn. + // Later pages and the snapshot query see only such rows and use `maxItems`, + // so this page uses it too. + if (userTurns === 0) selected.splice(policy.maxItems); selected.reverse(); const oldest = selected[0]; @@ -307,6 +290,44 @@ function findCursorIndex( return Math.min(cursor.p, items.length); } +/** + * Older pages allow twice the turns, and a targeted page widens further so it + * reaches `throughEntryId`. Callers that re-bound a page must reuse this policy + * or they drop the target again. + */ +export function olderHistoryPagePolicy(input: { + readonly items: ReadonlyArray; + readonly cursor: string; + readonly policy?: ThreadHistoryPagePolicy | undefined; + readonly throughEntryId?: string | undefined; +}): ThreadHistoryPagePolicy { + const policy = input.policy ?? { + ...THREAD_HISTORY_PAGE_POLICY, + maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT, + }; + if (input.throughEntryId === undefined) return policy; + const anchorIndex = findCursorIndex(input.items, decodeThreadHistoryCursor(input.cursor)); + const targetIndex = input.items.findIndex( + (row) => (row.item.messageId ?? row.item.id) === input.throughEntryId, + ); + if (targetIndex < 0 || targetIndex >= anchorIndex) return policy; + const turns = input.items + .slice(targetIndex, anchorIndex) + .filter((row) => isThreadHistoryUserTurn(row.item)).length; + return { + ...policy, + maxUserTurns: Math.min( + OLDER_THREAD_USER_TURN_LIMIT * 2, + Math.max( + OLDER_THREAD_USER_TURN_LIMIT, + turns + (isThreadHistoryUserTurn(input.items[targetIndex]!.item) ? 0 : 1), + ), + ), + maxItems: policy.maxItems * 2, + maxTurnItems: (policy.maxTurnItems ?? policy.maxItems) * 2, + }; +} + export function selectHistoryPageFromCursor(input: { readonly items: ReadonlyArray; readonly cursor: string; @@ -316,41 +337,42 @@ export function selectHistoryPageFromCursor(input: { readonly throughEntryId?: string | undefined; }): SelectTimelinePageResult { const decoded = decodeThreadHistoryCursor(input.cursor); - const anchorIndex = findCursorIndex(input.items, decoded); - let policy = input.policy ?? { - ...THREAD_HISTORY_PAGE_POLICY, - maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT, - }; - if (input.throughEntryId !== undefined) { - const targetIndex = input.items.findIndex( - (row) => (row.item.messageId ?? row.item.id) === input.throughEntryId, - ); - if (targetIndex >= 0 && targetIndex < anchorIndex) { - const turns = input.items - .slice(targetIndex, anchorIndex) - .filter((row) => isThreadHistoryUserTurn(row.item)).length; - policy = { - ...policy, - maxUserTurns: Math.min( - OLDER_THREAD_USER_TURN_LIMIT * 2, - Math.max( - OLDER_THREAD_USER_TURN_LIMIT, - turns + (isThreadHistoryUserTurn(input.items[targetIndex]!.item) ? 0 : 1), - ), - ), - maxItems: policy.maxItems * 2, - }; - } - } return selectOlderTimelinePage({ items: input.items, - exclusiveEndIndex: anchorIndex, + exclusiveEndIndex: findCursorIndex(input.items, decoded), snapshotSequence: input.snapshotSequence, ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }), - policy, + policy: olderHistoryPagePolicy(input), }); } +/** + * Row and byte budgets can split a turn between an interrupt result and its + * older request. Clients read a superseded result without a request as a + * legacy steer and hide it, so a page carries the requests its results need. + */ +export function missingInterruptRequests( + items: ReadonlyArray, + page: ReadonlyArray>, +): Row[] { + const runs = new Set(); + for (const row of page) { + if (row.item.type === "run_interrupt_result" && row.item.runId != null) + runs.add(row.item.runId); + } + for (const row of page) { + if (row.item.type === "run_interrupt_request" && row.item.runId != null) + runs.delete(row.item.runId); + } + if (runs.size === 0) return []; + return items.filter( + (row) => + row.item.type === "run_interrupt_request" && + row.item.runId != null && + runs.has(row.item.runId), + ); +} + function isLocalProjectedRow( projection: OrchestrationV2ThreadProjection, row: OrchestrationV2ProjectedTurnItem, @@ -358,12 +380,7 @@ function isLocalProjectedRow( return row.visibility === "local" || String(row.sourceThreadId) === String(projection.thread.id); } -/** - * Keep every small request item from the full projection even when it sits - * outside the recent visible window, so a later history page that introduces - * the matching result still has the request available for live attempt/run - * reducers. - */ +/** Retain small requests outside the visible window; later result pages still need them for attempt/run reducers. */ function retainedInterruptRequestTurnItems( projection: OrchestrationV2ThreadProjection, visible: ReadonlyArray, @@ -439,16 +456,12 @@ function messagesForBoundedProjection( retainedMessageIds.has(String(message.id)) || (message.runId !== null && retainedRunIds.has(String(message.runId))) || message.delegatedCompletion !== undefined || - // Clients open a workflow coordinator's thread from its script, however - // far back the history window starts. A thread has at most one. + // Keep the workflow script outside the history window; clients use it to open the coordinator thread. message.workflowScript === true, ); } -/** - * Used as a cheap partial-timeline watermark so old missing turn-item updates - * cannot append newest when the bounded window has no local rows. - */ +/** Watermark prevents old missing updates from appending newest when bounded windows lack local rows. */ export function computeLatestLocalTurnOrdinal( turnItems: ReadonlyArray<{ readonly ordinal: number }>, ): number | null { @@ -529,11 +542,7 @@ export function buildBoundedThreadProjection(input: { policy.maxEncodedBytes / 2, policy.maxEncodedBytes - dependencyReserve - controlBytes - 1_024, ); - const windowPolicy: ThreadHistoryPagePolicy = { - maxUserTurns: policy.maxUserTurns, - maxItems: policy.maxItems, - maxEncodedBytes: windowBudget, - }; + const windowPolicy: ThreadHistoryPagePolicy = { ...policy, maxEncodedBytes: windowBudget }; const window = selectRecentTimelineWindow({ items: controlProjection.visibleTurnItems, @@ -604,9 +613,5 @@ export function buildBoundedThreadProjection(input: { historyCursor: window.nextCursor, hasMoreHistory: window.hasMoreHistory, latestLocalTurnOrdinal, - payloadBudgetExceeded: - controlBytes > policy.maxEncodedBytes || - timelineTextExceedsBudget(projection, policy.maxEncodedBytes) || - bytesOfJson(projection) > policy.maxEncodedBytes, }; } diff --git a/apps/server/src/orchestration-v2/toolOutputImageBytes.ts b/apps/server/src/orchestration-v2/toolOutputImageBytes.ts index ae9a4af1246a..6cde6c5448c7 100644 --- a/apps/server/src/orchestration-v2/toolOutputImageBytes.ts +++ b/apps/server/src/orchestration-v2/toolOutputImageBytes.ts @@ -101,12 +101,8 @@ function strip( return next ?? record; } -/** - * Tools that read or capture an image return the whole file as base64, and a turn item is stored - * twice: in the event log and in the projection. Only the blocks `toolOutputImageBlocks` finds, - * within the asset size limit, are ever read back; previews of a read file load from - * `viewedImagePath`, and context handoff skips tool items. - */ +/** Image bytes are stored twice; retain only size-limited toolOutputImageBlocks that are served. + * Read-file previews use viewedImagePath, and context handoffs skip tools. */ export function stripUnservedToolOutputImageBytes( item: OrchestrationV2TurnItem, ): OrchestrationV2TurnItem { diff --git a/apps/server/src/orchestration-v2/workflowAgentTranscript.ts b/apps/server/src/orchestration-v2/workflowAgentTranscript.ts index 35ddea0a2dfe..059eb0740434 100644 --- a/apps/server/src/orchestration-v2/workflowAgentTranscript.ts +++ b/apps/server/src/orchestration-v2/workflowAgentTranscript.ts @@ -1,11 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -/** - * A workflow member never becomes a provider task and the CLI forwards none of - * its messages, so the transcript the harness writes is the only record of its - * conversation. The path is derived here from the run's transcript directory - * and the member's provider id rather than accepted from the caller, and the - * read itself is contained by readContainedWorkflowFile. - */ +/** Workflow members appear only in transcripts, not provider events. */ import * as NodePath from "node:path"; import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; @@ -33,7 +27,6 @@ export const initialWorkflowAgentTranscriptCursor: WorkflowAgentTranscriptCursor identity: null, }; -/** Sees every transcript read: where it started and ended, and how many lines it kept. */ export const WorkflowAgentTranscriptReadObserver = Context.Reference< (read: { readonly agentId: string; @@ -45,11 +38,7 @@ export const WorkflowAgentTranscriptReadObserver = Context.Reference< defaultValue: () => () => Effect.void, }); -/** - * Conversation lines only: `attachment` lines and `isMeta` user lines are - * harness bookkeeping (hook output, skill listings, injected context), and a - * line without a uuid cannot be projected idempotently. - */ +/** Skip harness bookkeeping (attachment and isMeta user lines) and lines without uuids needed for idempotent projection. */ function parseWorkflowAgentTranscriptLines(contents: string): ReadonlyArray { return contents.split("\n").flatMap((line) => { if (line.trim().length === 0) return []; @@ -92,7 +81,6 @@ export const readWorkflowAgentTranscript = Effect.fn("orchestration.readWorkflow return { lines, cursor: { offset: file.endOffset, identity: file.identity }, - // More complete lines may follow past the per-read cap. truncated: file.truncated, }; }, diff --git a/apps/server/src/orchestration-v2/workflowFileRead.ts b/apps/server/src/orchestration-v2/workflowFileRead.ts index 81486b631f70..af8356806c96 100644 --- a/apps/server/src/orchestration-v2/workflowFileRead.ts +++ b/apps/server/src/orchestration-v2/workflowFileRead.ts @@ -23,10 +23,7 @@ export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedW readonly byteCap: number; readonly tail?: boolean; readonly configDir?: string; - /** - * Read only complete lines from this byte offset of the file `identity` - * names. A different file (shrunk or another inode) is read from 0. - */ + /** Reads complete lines from a byte offset; resets to zero after shrinkage or inode changes. */ readonly fromOffset?: { readonly offset: number; readonly identity: WorkflowFileIdentity | null; @@ -50,8 +47,7 @@ export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedW }), }); - // Realpath the FILE itself (not just its directory): a symlink named - // like a transcript inside a contained directory must not escape. + // Resolve the file itself; contained directories can still hold escaping symlinks. const resolved = yield* Effect.tryPromise({ try: () => NodeFSP.realpath(input.path), catch: (cause) => @@ -68,8 +64,7 @@ export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedW }); } - // fd inode vs path inode catches a swapped leaf, not a swapped intermediate - // dir; root is 0700 ~/.claude, so that already needs the uid we run as. + // Inode checks catch leaf swaps, not intermediate directory swaps; the 0700 ~/.claude root restricts those to our uid. const read = yield* Effect.tryPromise({ try: async () => { const handle = await NodeFSP.open( @@ -98,14 +93,12 @@ export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedW const buffer = Buffer.alloc(Math.min(stat.size - start, input.byteCap)); const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); const read = buffer.subarray(0, bytesRead); - // A writer may be mid-line; leave it for the next read. A single - // line over the cap is skipped so the cursor can't stall on it. + // Defer partial lines; skip single over-cap lines so the cursor can advance. const lineEnd = read.lastIndexOf(0x0a) + 1; const consumed = lineEnd === 0 && bytesRead === input.byteCap ? bytesRead : lineEnd; return { contents: read.subarray(0, lineEnd).toString("utf8"), - // Only the cap can leave complete lines unread; a half-written - // last line is not truncation. + // Only the cap signals truncation; a half-written last line does not. truncated: stat.size - start > input.byteCap, startOffset: start, endOffset: start + consumed, diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index dc912bf39c3f..dece291aa1ca 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -68,8 +68,7 @@ export const fixPath = Effect.fn("fixPath")(function* (options?: { const env = yield* HostProcess.Environment; if (platform === "win32") { - // The desktop already ran both PowerShell probes into the env this child - // extends. Repeating them here costs seconds of startup on Windows. + // Desktop already populated the child env; repeating PowerShell probes costs seconds. if (options?.shellEnvironmentPrepared === true) return; const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( Effect.catchDefect((defect) => diff --git a/apps/server/src/outboundHttp.test.ts b/apps/server/src/outboundHttp.test.ts index be8a275fd4b2..ed3b09abaa5e 100644 --- a/apps/server/src/outboundHttp.test.ts +++ b/apps/server/src/outboundHttp.test.ts @@ -18,9 +18,7 @@ import { installHttp1FetchDispatcher, } from "./outboundHttp.ts"; -// Test-only self-signed P-256 certificate for 127.0.0.1, valid until 2126; not a -// secret. Node cannot mint X.509 certificates without a library, and a local -// server only offers h2 over ALPN, which needs TLS. +// Non-secret, self-signed 127.0.0.1 certificate, valid until 2126; ALPN h2 requires TLS. const TEST_KEY = `-----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3EwCztQysKTvzdcR g0LWq+axzUd4tHi9PyMdqh7PlGShRANCAARfQpoLgv/tOsHWCLriDza7Ruaey2b7 @@ -87,8 +85,7 @@ describe("installHttp1FetchDispatcher", () => { expect(Undici.getGlobalDispatcher()).toBeInstanceOf(Undici.EnvHttpProxyAgent); }); - // Only a runtime that bundles undici 8 (Node 26+) offers h2 through the - // built-in fetch, so only there can this prove fetch reads the new dispatcher. + // Only bundled undici 8 (Node 26+) offers fetch h2 and can prove dispatcher replacement. it.effect.runIf(builtinFetchUsesHttp2(process.versions.undici))( "moves the built-in fetch off HTTP/2", () => diff --git a/apps/server/src/outboundHttp.ts b/apps/server/src/outboundHttp.ts index aeb7f69aba97..ead14cb5d203 100644 --- a/apps/server/src/outboundHttp.ts +++ b/apps/server/src/outboundHttp.ts @@ -3,21 +3,14 @@ import * as NodeNet from "node:net"; import type * as Undici from "undici"; -// undici stays external to the CLI bundle (scripts/lib/cli-external-packages.ts). -// Inside a Node single-executable only `require` can load it from disk. +// undici is external to the CLI bundle; Node single-executables can load it from disk only with require. const requireUndici = NodeModule.createRequire(import.meta.url); -/** - * Node 26 bundles undici 8, which offers h2 over ALPN to every HTTPS origin. - * Node 22/24 (and Electron) bundle undici 6/7, whose fetch stays on HTTP/1.1. - */ +/** Node 26 bundles h2-capable undici 8; Node 22/24 and Electron stay on HTTP/1.1 with 6/7. */ export const builtinFetchUsesHttp2 = (bundledUndiciVersion: string | undefined) => Number.parseInt(bundledUndiciVersion ?? "", 10) >= 8; -/** - * Node's `--use-env-proxy` / `NODE_USE_ENV_PROXY` installs an - * `EnvHttpProxyAgent` at startup; that choice is kept so proxies still apply. - */ +/** Preserves the EnvHttpProxyAgent installed by --use-env-proxy / NODE_USE_ENV_PROXY. */ export function installHttp1FetchDispatcher(): void { const undici = requireUndici("undici") as typeof Undici; // Node's agent comes from its internal undici copy, so `instanceof` against the npm class is false. @@ -26,19 +19,14 @@ export function installHttp1FetchDispatcher(): void { usesEnvProxy ? new undici.EnvHttpProxyAgent({ allowH2: false, - // The connection to an HTTPS proxy is built here, not from `allowH2`. ProxyAgent's - // `connect` option is dropped on purpose: its connector has h2 fixed on. + // ProxyAgent's connector forces h2 and ignores allowH2; replace it for HTTPS proxies. clientFactory: (origin) => new undici.Pool(origin, { allowH2: false }), }) : new undici.Agent({ allowH2: false }), ); } -/** - * An undici HTTP/2 session that hits its idle timeout can emit `'error'` on a - * stream with no listener, which Node turns into an uncaught exception that - * exits the server (https://github.com/nodejs/undici/issues/5936). - */ +/** Avoids uncaught idle-timeout errors on h2 streams (https://github.com/nodejs/undici/issues/5936). */ export function keepOutboundFetchOnHttp1(): void { if (builtinFetchUsesHttp2(process.versions.undici)) installHttp1FetchDispatcher(); } @@ -71,7 +59,7 @@ export function ignoreMacosTypeOfServiceEinval( /** * Node's 250 ms Happy Eyeballs attempt timeout abandons a working-but-slow IPv4 * connect when IPv6 is advertised but unroutable, which broke the ACP registry - * fetch (#15039). A larger `--network-family-autoselection-attempt-timeout` still wins. + * fetch. A larger `--network-family-autoselection-attempt-timeout` still wins. */ export function extendConnectionAttemptTimeout(): void { NodeNet.setDefaultAutoSelectFamilyAttemptTimeout( diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index 4a8bc699b7d8..4e2d572bd96b 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -121,7 +121,7 @@ function toPersistenceSqlOrDecodeError( }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 687c2c019b9c..a907b04df7c6 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -205,7 +205,7 @@ function toPersistenceSqlOrDecodeError( }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/DatabaseMaintenance.test.ts b/apps/server/src/persistence/DatabaseMaintenance.test.ts index 363256120a55..6a53bad877fe 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.test.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.test.ts @@ -53,7 +53,6 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) }).pipe(Effect.provide(openLegacy)); assert.isAbove((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes ?? 0, 0); - // A version before 0.1.0 still running against it keeps it. const refused = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`SELECT value FROM v1_data`; @@ -77,7 +76,6 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-compact-" }); const dbPath = path.join(baseDir, "userdata", "statev2.sqlite"); - // A migrated database created before incremental auto_vacuum, with free pages. yield* Effect.flatMap( ProjectionMaintenance.ProjectionMaintenanceV2, (projections) => projections.rebuild, @@ -98,10 +96,23 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) yield* sql`CREATE TABLE discarded_data (value BLOB)`; yield* sql`INSERT INTO discarded_data VALUES (zeroblob(4194304))`; yield* sql`DROP TABLE discarded_data`; + yield* sql` + INSERT INTO orchestration_v2_projection_messages ( + message_id, thread_id, run_id, node_id, role, streaming, created_at, updated_at, payload_json + ) + VALUES ( + 'message-1', 'thread-1', NULL, NULL, 'user', 0, + '2026-09-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '{"text":"needle"}' + ) + `; + // An index that drifted from its rows, as a renumbering VACUUM would leave it. + yield* sql` + INSERT INTO orchestration_v2_message_search (orchestration_v2_message_search) + VALUES ('delete-all') + `; yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath }))); - // A server that merely has the database open, like an idle one, is enough to refuse. const sizeBefore = (yield* fs.stat(dbPath)).size; const refused = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -112,12 +123,21 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) assert.equal((yield* fs.stat(dbPath)).size, sizeBefore); const result = yield* maintenance.compact(dbPath); - // The 4 MiB of free pages are returned to the filesystem. assert.isBelow(result.afterBytes, result.beforeBytes - 3 * 1024 * 1024); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; assert.deepEqual(yield* sql`PRAGMA auto_vacuum`, [{ auto_vacuum: 2 }]); assert.deepEqual(yield* sql`SELECT value FROM retained_data`, [{ value: "keep" }]); + assert.deepEqual( + yield* sql` + SELECT messages.message_id AS "messageId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = search.rowid + WHERE orchestration_v2_message_search MATCH '"needle"' + `, + [{ messageId: "message-1" }], + ); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true }))); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/persistence/DatabaseMaintenance.ts b/apps/server/src/persistence/DatabaseMaintenance.ts index 72af08b04c86..8cbd91567651 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.ts @@ -11,6 +11,7 @@ import { isSqlError } from "effect/sql/SqlError"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import { rebuildMessageSearch } from "../orchestration-v2/messageSearchIndex.ts"; export class DatabaseMissingError extends Schema.TaggedError()( "DatabaseMissingError", @@ -66,7 +67,6 @@ export class LegacyDatabaseRemoveError extends Schema.TaggedError; - /** - * Deletes the database from before 0.1.0 kept beside this one. Versions - * before 0.1.0 read only that file; this one stays intact. - */ + /** Deletes only the pre-0.1.0 database; earlier versions still use it. */ readonly removeLegacyDatabase: ( databasePath: string, ) => Effect.Effect; @@ -180,7 +177,6 @@ const make = Effect.gen(function* () { const beforeBytes = Number((yield* fs.stat(databasePath)).size); const result = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // Held through compaction and VACUUM. yield* lockExclusively(databasePath); const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; if (!(yield* maintenance.verify).valid) { @@ -190,10 +186,10 @@ const make = Effect.gen(function* () { if (!(yield* maintenance.verify).valid) { return yield* new DatabaseProjectionsInvalidError({ path: databasePath }); } - // VACUUM rebuilds the file, which is the only way to switch an existing - // database to incremental auto_vacuum; the server reclaims online after. + // VACUUM is required to enable incremental auto_vacuum on existing databases. yield* sql`PRAGMA auto_vacuum = INCREMENTAL`; yield* sql`VACUUM`; + yield* rebuildMessageSearch(sql); yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`; return summary; }).pipe( @@ -224,9 +220,7 @@ const make = Effect.gen(function* () { function* (databasePath: string) { const legacy = legacyPath(databasePath); if (!(yield* fs.exists(legacy))) return; - // A version before 0.1.0 may still be running against it. Keep the lock - // while deleting, and delete the database before its WAL so a failure - // never leaves it without committed transactions. + // Older servers may use this file; hold its lock and delete the database before WAL to preserve commits on failure. yield* Effect.gen(function* () { yield* lockExclusively(legacy); for (const suffix of ["", "-wal", "-shm"]) { diff --git a/apps/server/src/persistence/DatabaseStorage.ts b/apps/server/src/persistence/DatabaseStorage.ts index 2dfcd22c0c85..586fb023ae04 100644 --- a/apps/server/src/persistence/DatabaseStorage.ts +++ b/apps/server/src/persistence/DatabaseStorage.ts @@ -17,7 +17,7 @@ import * as ServerConfig from "../config.ts"; import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; import { forkParked } from "../serverActivation.ts"; -/** Pages returned per reclaim pass; one minute of churn, not a backlog. */ +// Bound each reclaim pass to one minute of churn. const RECLAIM_PAGES_PER_PASS = 1024; const COMPACTION_INTERVAL = Duration.hours(6); @@ -31,12 +31,7 @@ export class DatabaseStorage extends Context.Service< } >()("t3/persistence/DatabaseStorage") {} -/** - * Deletes superseded history every few hours and hands freed pages back to - * the filesystem every minute. Deleted events are always superseded by a - * retained event with a higher sequence, so a client replaying from an older - * cursor still reaches the same state; no replay boundary is published. - */ +// Deleted events have newer retained replacements; old cursors replay to the same state. const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; @@ -45,15 +40,12 @@ const make = Effect.gen(function* () { const path = yield* Path.Path; const statusRef = yield* SubscriptionRef.make(null); - // The notice shows this verbatim; quote for the host's shell. Desktop - // installs have no `t3` on PATH, so the command names their launcher. + // The notice shows host-shell quoting verbatim; desktop needs its launcher because t3 is absent from PATH. const { command: compactCommand } = yield* resolveHostCliCommand("doctor", [ "--base-dir", config.baseDir, ]); - // `t3 doctor --base-dir` repairs /userdata. A dev server without - // an explicit home keeps its state in /dev instead, so there the - // command would target the live install; publish no status at all. + // doctor --base-dir targets userdata, not implicit dev homes; suppress status to avoid touching the live install. const doctorTargetsThisDatabase = path.resolve(config.dbPath) === path.resolve(config.baseDir, "userdata", path.basename(config.dbPath)); @@ -81,9 +73,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; - // The driver steps a statement without result columns once, and each - // step of incremental_vacuum frees one page. Yield between pages so - // client requests can run. + // 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`; if ((free[0]?.freelist_count ?? 0) === 0) break; diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 308bb128068e..7d95ac70fb3d 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -83,10 +83,7 @@ function sqliteCondition(cause: unknown): string | undefined { return undefined; } -/** - * A rejected payload must never reach diagnostics, so a schema failure contributes only its - * issue tags, and a driver failure only its normalized condition. - */ +// Keep rejected payloads out of diagnostics; report only schema issue tags or normalized driver conditions. function describeSqlCause(cause: unknown): string | undefined { if (Schema.isSchemaError(cause)) return summarizeSchemaIssue(cause.issue); return sqliteCondition(cause); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5b8ca15241ba..f85cb14e309b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -71,6 +71,7 @@ import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts"; import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts"; import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; import Migration0068 from "./Migrations/068_ShellStopRequestIndex.ts"; +import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -142,6 +143,7 @@ const migrationEntries = [ [66, "RepairClientSettlementTimestamps", Migration0066], [67, "WebhookDedupe", Migration0067], [68, "ShellStopRequestIndex", Migration0068], + [69, "ThreadMessageSearch", Migration0069], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -157,12 +159,7 @@ const makeMigrationLoader = (throughId?: number) => const FIRST_V2_MIGRATION_ID = 55; -/** - * Fail before migrating when the migrator would skip one of V2's own - * migrations. It skips every id up to the highest recorded one, whatever name - * was recorded, so a database that took a V2 id for another migration would - * start without V2's schema and fail on first use. - */ +// Migrators skip ids up to the highest recorded; reject collisions that would skip V2 schema. const assertV2MigrationsCanRun = Effect.fn("assertV2MigrationsCanRun")(function* ( throughId?: number, ) { diff --git a/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts b/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts index 2363d8308c22..b7c4e56c46f0 100644 --- a/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts +++ b/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts @@ -1,9 +1,4 @@ -/** - * The column is nullable on purpose — backfilling it during the migration would - * require knowing which configured instance "owned" each historical session, - * and that mapping is ambiguous when the user later configures multiple - * instances of the same driver. - */ +// Historical sessions cannot be mapped unambiguously to configured instances; leave the column nullable. import * as SqlClient from "effect/sql/SqlClient"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.ts b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.ts index e9bc08195f3d..b9f23fd1daeb 100644 --- a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.ts +++ b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.ts @@ -1,10 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; -/** - * This is an intentional alpha cutover: role-bearing credentials and sessions - * cannot safely be assigned new capabilities implicitly. - */ +/** Role-bearing credentials and sessions cannot safely receive new capabilities implicitly. */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts index 96b13f786059..5db41191508f 100644 --- a/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts +++ b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts @@ -1,12 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; -/** - * Pagination orders turns by the stable keyset (requested_at, turn_id); the - * pre-existing (thread_id, requested_at) index cannot serve the tiebreak order, - * forcing a temp B-tree over all of a thread's turns before the page LIMIT - * applies. - */ +/** Keyset pagination needs both requested_at and turn_id indexed to avoid sorting all turns before LIMIT. */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql` diff --git a/apps/server/src/persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts b/apps/server/src/persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts index 9ecfe949a389..c1f8545b21d0 100644 --- a/apps/server/src/persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts +++ b/apps/server/src/persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts @@ -4,8 +4,7 @@ import * as SqlClient from "effect/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // Project creation never exposed a model choice. A later metadata event - // containing this field is the evidence that the user set or reset one. + // Creation exposed no model choice; later metadata proves the user set or reset one. yield* sql` WITH automatically_seeded_projects AS ( SELECT created.stream_id AS project_id diff --git a/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts index 8dfbf0f89427..3a3fa3192d79 100644 --- a/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts +++ b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts @@ -1,11 +1,8 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; -// Repair the projection only: the engine and projectors bootstrap from -// projection rows and cursors, never a full replay, so the historical event -// payloads can stay as they were recorded. The decider stamped settledAt and -// occurred_at from the same clock read, which is how an unrepaired automatic -// settlement is identified below. +// Bootstrap uses projections and cursors, so repair projections only. +// Matching settledAt and occurred_at identifies automatic settlements from the decider's shared clock read. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts index 0ee0f00c0b33..f130aae7e10a 100644 --- a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts @@ -1,4 +1,4 @@ -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/053_PullRequestFilesViewed.ts b/apps/server/src/persistence/Migrations/053_PullRequestFilesViewed.ts index 733aa74e0e71..22728ff5705f 100644 --- a/apps/server/src/persistence/Migrations/053_PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/Migrations/053_PullRequestFilesViewed.ts @@ -4,8 +4,7 @@ import * as SqlClient from "effect/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // Unticking deletes the row: absent is the resting state, and a table of "not viewed" - // rows would grow with every diff anybody scrolled past. + // Delete unticked rows; storing the absent state would grow with every scrolled diff. yield* sql` CREATE TABLE IF NOT EXISTS pull_request_files_viewed ( provider TEXT NOT NULL, diff --git a/apps/server/src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.ts b/apps/server/src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.ts index bc3245ea64cb..db914aa6fc11 100644 --- a/apps/server/src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.ts +++ b/apps/server/src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.ts @@ -4,8 +4,7 @@ import * as SqlClient from "effect/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // Later ordering indexes contain these exact column prefixes, so SQLite can - // serve the same lookups without maintaining a second B-tree on every write. + // Later indexes cover the same column prefixes; avoid maintaining duplicate B-trees. yield* sql`DROP INDEX IF EXISTS idx_projection_threads_project_id`; yield* sql`DROP INDEX IF EXISTS idx_projection_thread_messages_thread_created`; yield* sql`DROP INDEX IF EXISTS idx_projection_thread_activities_thread_sequence`; diff --git a/apps/server/src/persistence/Migrations/058_WebhookRelayDeliveries.ts b/apps/server/src/persistence/Migrations/058_WebhookRelayDeliveries.ts index 6e1c264c1647..c32d8e9f6aaf 100644 --- a/apps/server/src/persistence/Migrations/058_WebhookRelayDeliveries.ts +++ b/apps/server/src/persistence/Migrations/058_WebhookRelayDeliveries.ts @@ -4,9 +4,7 @@ import * as SqlClient from "effect/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // Relay delivery ids already handled, kept longer than the relay holds a - // request so a replay can never run twice. The delivery log keeps only the - // newest 50 rows per task, which is too short for that. + // Retain handled ids longer than relay requests; the 50-row delivery log cannot prevent all replays. yield* sql` CREATE TABLE IF NOT EXISTS scheduled_task_webhook_relay_deliveries ( relay_delivery_id TEXT PRIMARY KEY, diff --git a/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts index 6bbb1f701e97..ad62bc552662 100644 --- a/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts +++ b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts @@ -4,7 +4,6 @@ import * as SqlClient from "effect/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // ProjectionStore's turn_anchors CTE relies on it. yield* sql` CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_user_message_idx ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) diff --git a/apps/server/src/persistence/Migrations/063_DropUnusedV1Projections.ts b/apps/server/src/persistence/Migrations/063_DropUnusedV1Projections.ts index 9e795338bcb9..e806b42cec29 100644 --- a/apps/server/src/persistence/Migrations/063_DropUnusedV1Projections.ts +++ b/apps/server/src/persistence/Migrations/063_DropUnusedV1Projections.ts @@ -1,13 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; -/** - * V2 databases start as a copy of the V1 database, and nothing reads these V1 - * projections after the copy. The V1 transcript import still reads threads, - * messages and pull request links, so those stay. Builds before 0.1.0 read - * `state.sqlite`, never this file, so dropping them does not affect a rollback. - * The freed pages are reported as reclaimable space for `t3 doctor`. - */ +// V2 never reads these copied V1 projections; pre-0.1.0 builds use state.sqlite, preserving rollback. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts index 7135696246c6..9364a8d817c4 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts @@ -1,5 +1,5 @@ import { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts new file mode 100644 index 000000000000..029218bb67b1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts @@ -0,0 +1,55 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; + +const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" })); + +layer("069_ThreadMessageSearch", (it) => { + it.effect("backfills what users wrote and agents answered, then follows deletes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 68 }); + const insert = ( + id: string, + role: string, + streaming: number, + text: string, + createdBy: string | undefined = role === "user" ? "user" : "agent", + ) => sql` + INSERT INTO orchestration_v2_projection_messages ( + message_id, thread_id, run_id, node_id, role, streaming, created_at, updated_at, payload_json + ) + VALUES ( + ${id}, 'thread-1', NULL, NULL, ${role}, ${streaming}, + '2026-09-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', ${JSON.stringify({ text, createdBy })} + ) + `; + yield* insert("user", "user", 0, "needle from the user"); + yield* insert("assistant", "assistant", 0, "needle from the agent"); + yield* insert("streaming", "assistant", 1, "needle still streaming"); + yield* insert("system", "system", 0, "needle in a system prompt"); + yield* insert("notice", "user", 0, "needle in a pull request update", "agent"); + // Older rows carry no author; they were the user's. + yield* insert("legacy", "user", 0, "needle from before authors", undefined); + + yield* runMigrations(); + + const indexed = () => + sql<{ readonly messageId: string }>` + SELECT messages.message_id AS "messageId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = search.rowid + WHERE orchestration_v2_message_search MATCH '"needle"' + ORDER BY messages.message_id + `.pipe(Effect.map((rows) => rows.map((row) => row.messageId))); + assert.deepEqual(yield* indexed(), ["assistant", "legacy", "user"]); + + yield* sql`DELETE FROM orchestration_v2_projection_messages WHERE message_id = 'user'`; + assert.deepEqual(yield* indexed(), ["assistant", "legacy"]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts new file mode 100644 index 000000000000..4652debcbfd7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts @@ -0,0 +1,90 @@ +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + indexedMessageCondition, + indexedMessageText, +} from "../../orchestration-v2/messageSearchIndex.ts"; + +/** + * Trigrams match 3+ character substrings without a scan, ignoring case and accents. + * Generated user-role notices would flood results and appear as "You". + * VACUUM can change rowids, so `t3 doctor` must rebuild this contentless index afterward. + * Index only finished messages, folding apostrophes as queries do; scan if the index is missing. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Older system SQLite builds may lack these FTS5 options; search can scan messages instead. + // Only SQLITE_ERROR signals unsupported options. Disk and I/O failures must fail the migration + // so the next startup retries creating the index. + const created = yield* sql` + CREATE VIRTUAL TABLE orchestration_v2_message_search USING fts5( + text, + content = '', + contentless_delete = 1, + tokenize = 'trigram remove_diacritics 1' + ) + `.pipe( + Effect.as(true), + Effect.catchIf( + (cause) => + Predicate.hasProperty(cause.reason.cause, "errcode") && cause.reason.cause.errcode === 1, + (cause) => + Effect.logWarning( + "Message search index unavailable; search will scan messages.", + cause, + ).pipe(Effect.as(false)), + ), + ); + if (!created) return; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + WHEN ${indexedMessageCondition(sql, "new")} + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + // Streaming deltas rewrite the row many times a second; skip them until the + // message settles. Upserts rewrite every column, so a finished message + // re-indexes only when what the index reads changed. Deleting a rowid the + // index never held is a no-op. + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_update + AFTER UPDATE OF payload_json, streaming, role ON orchestration_v2_projection_messages + WHEN (old.streaming = 0 OR new.streaming = 0) + AND ( + old.streaming IS NOT new.streaming + OR old.role IS NOT new.role + OR json_extract(old.payload_json, '$.text') IS NOT json_extract(new.payload_json, '$.text') + OR json_extract(old.payload_json, '$.createdBy') + IS NOT json_extract(new.payload_json, '$.createdBy') + ) + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid AND old.streaming = 0; + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE ${indexedMessageCondition(sql, "new")} + AND json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_delete + AFTER DELETE ON orchestration_v2_projection_messages + WHEN old.streaming = 0 + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; + END + `; + yield* sql` + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT messages.rowid, ${indexedMessageText(sql, "messages.payload_json")} + FROM orchestration_v2_projection_messages AS messages + WHERE ${indexedMessageCondition(sql)} + AND json_extract(messages.payload_json, '$.text') IS NOT NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts index e743e1dea6e1..00924b565899 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts @@ -122,8 +122,7 @@ export default Effect.gen(function* () { { concurrency: 1, discard: true }, ); - // Re-baseline every current row into the shared log so projection rebuilds - // preserve the exact pre-migration project state. + // Re-baseline current rows so shared-log rebuilds preserve pre-migration project state. const projectRows = yield* sql` SELECT project_id, diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts index c79e585ec5b6..022d8fb9b974 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts @@ -1,11 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; -/** - * The original V2 schema used a single `provider` column for both configured instance - * routing and driver identity. They remain write-through shadows where the old schema - * declared them NOT NULL; all V2 reads and indexes use the explicit columns added here. - */ +// Legacy NOT NULL provider columns remain write-through shadows; V2 reads explicit driver/instance columns. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts new file mode 100644 index 000000000000..c5c0f80b8d9d --- /dev/null +++ b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts @@ -0,0 +1,109 @@ +import type { ThreadLinkedPullRequest, ThreadPullRequestKey } from "@t3tools/contracts"; + +/** + * Frozen for migrations 050 and 064: the change request URL parsing and key normalization they + * shipped with. Migrations are history, so they must not follow the live source control hosts. + */ + +interface FrozenLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly authority?: string; +} + +function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} + +function isHostOf(hostname: string, apex: string, label: string): boolean { + return hostname === apex || hostname.endsWith(`.${apex}`) || hostname.split(".").includes(label); +} + +function claim(host: string, match: RegExpExecArray | null): FrozenLink | null { + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { host, repository: repository.toLowerCase(), number } + : null; +} + +function parseChangeRequestUrl(targetUrl: string): FrozenLink | null { + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } else { + if (isHostOf(host, "github.com", "github")) { + const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (forgejo) { + const link = claim(host, forgejo); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; + } + const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (gitlab) return claim(host, gitlab); + if (isHostOf(host, "bitbucket.org", "bitbucket")) { + const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + } + const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + const link = claim(host, azure); + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; +} + +function normalizeThreadPullRequestKey( + key: ThreadPullRequestKey & { readonly authority?: string }, +): ThreadPullRequestKey { + const canonical = canonicalRepositoryKey( + `${(key.authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + ); + const separator = canonical.indexOf("/"); + return { + host: canonical.slice(0, separator), + repository: canonical.slice(separator + 1), + number: key.number, + }; +} + +export function legacyThreadPullRequestKey( + linked: Pick, +): ThreadPullRequestKey { + const parsed = parseChangeRequestUrl(linked.url); + if (parsed !== null && parsed.number === linked.number) { + const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + return normalizeThreadPullRequestKey(parsed); + } + } + let host: string; + try { + host = new URL(linked.url).hostname; + } catch { + host = "unknown"; + } + return { + host: host.trim().toLowerCase() || "unknown", + repository: linked.repository.trim().toLowerCase(), + number: linked.number, + }; +} diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 265bb6ad0fbf..51c3861a9fca 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -51,11 +51,7 @@ export class OrchestrationEventStore extends Context.Service< readonly events: ReadonlyArray; }) => Effect.Effect, OrchestrationEventStoreError>; - /** - * A row whose `event_type` this build does not know fails the read unless - * `skipUnknownEventTypes` is set, which logs and drops it instead. Only - * client thread replay opts in; anything that builds server state stays strict. - */ + /** Unknown event types fail unless skipUnknownEventTypes logs and drops them; only client replay may opt in. */ readonly readAgentEvents: (input?: { readonly afterSequence?: number; readonly throughSequence?: number; @@ -97,11 +93,7 @@ export class OrchestrationEventStore extends Context.Service< readonly latestApplicationSequence: Effect.Effect; - /** - * A row whose `event_type` this build does not know fails the read unless - * `skipUnknownEventTypes` is set, which logs and drops it instead. Only - * client shell replay opts in; anything that builds server state stays strict. - */ + /** Unknown event types fail unless skipUnknownEventTypes logs and drops them; only client replay may opt in. */ readonly readApplicationEvents: (input: { readonly afterSequence: number; readonly throughSequence: number; @@ -417,8 +409,7 @@ const makeEventStore = Effect.gen(function* () { correlation_id FROM orchestration_events ${ - // The sequence range otherwise wins and a command lookup scans every - // V2 event, which blocks the server for seconds on large histories. + // Otherwise the sequence index wins and command lookups scan every V2 event. input.commandId !== undefined ? sql`INDEXED BY idx_orch_events_command_id` : input.onlyAgentEvents === true @@ -459,9 +450,7 @@ const makeEventStore = Effect.gen(function* () { const promptKey = subagent === undefined ? undefined : `${event.type}:${subagent.id}`; const hint = promptKey === undefined ? undefined : promptHints.get(promptKey); const payloadJson = yield* encodeJson(encoded.payload); - // Progress ticks repeat an unchanged prompt, so store it once and point - // later rows at it. ProjectionMaintenance never deletes these anchor - // rows. + // Deduplicate repeated progress prompts via anchor rows that ProjectionMaintenance never deletes. const storedPayload = hint !== undefined && subagent !== undefined ? sql`CASE WHEN EXISTS ( diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 861be3c01742..e3395b74884a 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -134,12 +134,11 @@ function toPersistenceSqlOrDecodeError( }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // Runtime writes can carry stale payloads. Only recordImportedTranscript may - // change source records, so restore that field from the row being updated. + // Runtime payloads may be stale; only recordImportedTranscript can change source records. const upsertRuntimeRow = SqlSchema.void({ Request: ProviderSessionRuntimeDbRowSchema, execute: (runtime) => diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index 99f79e4936ef..f3673ff3fd12 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -25,12 +25,7 @@ export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Typ export const PullRequestFileViewedMark = Schema.Struct({ path: Schema.String, - /** - * Empty where the host said it had none to give, which is an answer rather than a gap: a file - * with no version at the head is one the change request deletes. Null where the host could - * not say at all, which is no baseline rather than an empty one, and such a mark stays - * cleared until a press replaces it. - */ + /** Empty means the host confirms deletion; null means no baseline, so the mark stays cleared until replaced. */ revision: Schema.NullOr(Schema.String), }); export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type; diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 58895755ff98..45b28834a4ad 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -22,7 +22,6 @@ it.effect("drops planner statistics a database already has", () => { const sql = yield* SqlClient.SqlClient; yield* sql`CREATE TABLE walk_probe(node_id TEXT PRIMARY KEY, parent_node_id TEXT)`; yield* sql`INSERT INTO walk_probe VALUES ('node-1', NULL), ('node-2', 'node-1')`; - // Stats from a nearly empty table, as an older build collected on a new install. yield* sql`ANALYZE walk_probe`; }).pipe(Effect.provide(persistence())); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index aeff92f08aa6..48c1ab8fc891 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -17,32 +17,22 @@ const layerSetup = Layer.effectDiscard( // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; - // Only an empty file can switch modes here; an existing database needs the - // offline VACUUM that `t3 doctor` runs, which rebuilds it with pointer maps. + // Existing databases need doctor's offline VACUUM to add pointer maps for this mode. const tables = yield* sql<{ readonly count: number; }>`SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table'`; if (tables[0]?.count === 0) yield* sql`PRAGMA auto_vacuum = INCREMENTAL;`; yield* sql`PRAGMA journal_mode = WAL;`; - // FULL fsyncs the WAL on every commit, on the main thread. Every provider - // event is its own commit, so a slow or busy disk stalls the event loop - // and clients drop. In WAL mode NORMAL only fsyncs at checkpoints. It - // cannot corrupt the database and an app crash loses nothing; an OS crash - // or power loss can roll back commits since the last checkpoint. + // FULL fsyncs every event commit on the main thread; NORMAL syncs only at WAL checkpoints. + // App crashes lose nothing; OS crashes or power loss can roll back post-checkpoint commits without corruption. yield* sql`PRAGMA synchronous = NORMAL;`; - // Checkpoints are now the only sync, and macOS fsync() does not flush the - // drive cache. F_FULLFSYNC makes them reach disk; other platforms ignore it. + // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere. yield* sql`PRAGMA checkpoint_fullfsync = ON;`; - // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its - // largest size until the last connection closes. + // PASSIVE checkpoints retain peak WAL size until the last connection closes. yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); - // No planner statistics. Collecting them reads every page of the large - // tables, and stats taken while a table was small make the planner scan it - // once it grows. Without them SQLite assumes large tables and uses indexes; - // the few queries that still pick a worse index name theirs with INDEXED BY. - // Older builds collected stats, so clear them. A connection loads stats - // when it opens, so this one must reload them after the delete. + // Collecting stats scans every page, and stale small-table stats cause scans after growth; use indexes by default. + // Connections cache stats; reload after deleting them. const statTables = yield* sql`SELECT 1 FROM sqlite_schema WHERE name = 'sqlite_stat1'`; if (statTables.length > 0) { yield* sql`DELETE FROM sqlite_stat1;`; @@ -61,8 +51,7 @@ export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (d NodeSqliteClient.layer({ filename: dbPath, readerWorker: true, - // Checkpoints sync the database, with F_FULLFSYNC on macOS. Run them off - // the event loop so a slow flush cannot stall clients. + // Run checkpoint fsyncs off the event loop so slow disk flushes cannot stall clients. checkpointWorker: true, spanAttributes: { "db.name": path.basename(dbPath), diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 64f1d9e3ae2f..e5a9b86fccbb 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -48,6 +48,7 @@ describe("V2 preview upgrade", () => { [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], + [69, "ThreadMessageSearch"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -139,6 +140,7 @@ describe("V2 preview upgrade", () => { [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], + [69, "ThreadMessageSearch"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.ts index cbf83ef55f57..235863bc1e8e 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.ts @@ -5,8 +5,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import PullRequestFilesViewed from "./Migrations/053_PullRequestFilesViewed.ts"; import AutoSettleDisabledAt from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; -// Published previews assigned V2 to 53, then 54. Keep their schema and import -// progress intact while reserving main's migration ids for upgrades from main. +// Previews used V2 ids 53/54; preserve their schema and import progress while reserving main's ids. export const reconcileV2PreviewMigration = Effect.fn("reconcileV2PreviewMigration")(function* () { const sql = yield* SqlClient.SqlClient; return yield* sql.withTransaction( diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 0d1db856c1e6..4d8ab5e098c3 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -1,10 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's fds. -/** - * Playwright connects over CDP only through a WebSocket URL, so each attached - * desktop tab gets a loopback endpoint with an unguessable path. Its frames - * cross the bootstrap file descriptors to the desktop's relay, which owns the - * tab's `webContents.debugger`. - */ +/** Playwright needs a CDP WebSocket; the secret loopback endpoint relays desktop debugger frames over bootstrap fds. */ import * as NodeStream from "@effect/platform-node/NodeStream"; import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer"; import { @@ -44,10 +39,7 @@ export class DesktopBrowserChannel extends Context.Service< DesktopBrowserChannel, { readonly available: boolean; - /** - * Subscribes before it checks, so an attach landing in between is never - * missed. - */ + /** Subscribe before checking so concurrent attaches cannot be missed. */ readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect; readonly detached: Stream.Stream; readonly attached: Stream.Stream; @@ -91,8 +83,7 @@ const make = Effect.gen(function* () { ), ); - // Socket reads can be cancelled while the desktop keeps its write end open. - // Filesystem reads on this pipe keep Node alive after scope shutdown. + // Use cancellable socket reads; filesystem pipe reads keep Node alive after scope shutdown. const readable = yield* Effect.acquireRelease( Effect.sync(() => new NodeNet.Socket({ fd: inputFd, readable: true, writable: false })), (stream) => Effect.sync(() => stream.destroy()), diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index a3b058de1c75..511970b9d766 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -16,19 +16,11 @@ interface EventCollector { readonly drain: Effect.Effect>; } -/** - * Each `it.effect` shares the live PreviewManager layer across the whole - * `it.layer` block, so tests that assert per-thread counts must use a unique - * thread id to avoid bleeding state from earlier tests. - */ +/** The shared live layer retains state between tests; counts need unique thread ids. */ let nextThreadId = 0; const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); -/** - * Subscribe to the manager's event stream BEFORE the test publishes. We - * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so - * no event can land between subscribe and the consumer drain. - */ +/** Subscribe synchronously before publishing so the forked consumer cannot miss events. */ const collectEvents = Effect.gen(function* () { const manager = yield* PreviewManager.PreviewManager; const subscription = yield* manager.subscribeEvents; diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index a21a1fe406fa..370ba6486fd4 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -119,7 +119,7 @@ const normalizeUrl = (rawUrl: string): Effect.Effect(); const events: Stream.Stream = Stream.fromPubSub(eventsPubSub); - /** - * The event is published INSIDE the lock so observers see events in the same - * order as the underlying state transitions. - */ + /** Publish inside the lock to preserve state-transition order. */ const mutateExistingSession = ( threadId: string, tabId: string, diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index e6f1069155b3..bd4a3c3733bf 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -370,7 +370,7 @@ const serversEqual = ( const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound"; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* PortDiscoveryMake() { const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; @@ -441,9 +441,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const known = yield* Ref.get(socketOwnersRef); const knownMisses = yield* Ref.get(socketOwnerMissesRef); const inodes = new Set(listeners.map((listener) => listener.inode)); - // Listeners rarely change, so the fd walk runs only for sockets without a - // confirmed owner, and gives up on one after a few walks miss it (another - // user's process, or one that exited mid-walk). + // Walk fds only for unowned sockets; stop after repeated misses for inaccessible or exited processes. const unseen = new Set( [...inodes].filter( (inode) => !known.has(inode) && (knownMisses.get(inode) ?? 0) < SOCKET_OWNER_ATTEMPTS, @@ -531,8 +529,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } for (const server of servers) { - // A listener alone does not identify its protocol. Sending HTTP or TLS to - // another app's binary RPC listener can crash it before classification. + // Never probe an unidentified listener with HTTP or TLS; binary RPC servers can crash on it. if (server.terminal === null) continue; groups.push({ server, @@ -624,8 +621,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { return { discovered, configured } satisfies WebProbeSnapshot; }); - // `tasklist` costs ~200-300ms per PID, so look up only visible servers, a few at a time, and - // reuse each name until its TTL expires. + // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. const withWindowsProcessNames = Effect.fn("PortDiscovery.withWindowsProcessNames")(function* ( snapshot: WebProbeSnapshot, ) { @@ -707,8 +703,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const listeners = yield* Effect.all([runNetstat("tcp"), runNetstat("tcpv6")], { concurrency: 2, }).pipe( - // One failed query leaves a partial snapshot that could hide another process sharing a - // port across IPv4/IPv6, so fall back to configured URLs instead. + // Failed queries can hide other IPv4/IPv6 listeners on the port; fall back to configured URLs. Effect.map(([tcp4, tcp6]) => exitedWithError(tcp4) || exitedWithError(tcp6) ? null diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 9a9daf823b03..0cb1665d4f3d 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -15,7 +15,7 @@ import * as NodeZlib from "node:zlib"; import * as PreviewBrowser from "./PreviewBrowser.ts"; -// POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite. +// NTFS cannot enforce POSIX modes; choose the host platform. const hostPlatform: NodeJS.Platform = HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; const executableName = diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index 0c2181b6ece0..d13b495ef5c2 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -21,35 +21,35 @@ import * as NodeCrypto from "node:crypto"; import * as ServerConfig from "../config.ts"; import { openZipArchive } from "../zipArchive.ts"; -// To bump the pin, pick a version from -// https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json, -// download each platform's chrome-headless-shell zip, and replace the version and every byte count -// and SHA-256 below. -const VERSION = "154.0.8037.92"; +// Update the version, sizes and hashes from each chrome-headless-shell zip listed at +// https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json +// Never pin below 156.0.8078.70: the shell has no CRLSet and older SPKI blocklists trust +// compromised eMudhra intermediates (crrev.com/c/8627687). +const VERSION = "156.0.8078.70"; const ARCHIVES = { linux64: { - bytes: 120_477_194, - sha256: "636aa5c79f2693632e9921b8bbb050038ba11672e02346c06c20f991aed096f9", + bytes: 126_646_931, + sha256: "a313da592261ff1e342c1e507d315f25b1809006c0508f77ce86151d464d4ccb", }, "linux-arm64": { - bytes: 121_182_296, - sha256: "0ed0e47d9e9f639197f508d62ada09e5c6b4c4c60edab3160a9312a733091df6", + bytes: 126_749_876, + sha256: "dca11213d2e0b1db52141edd9f92ec423fd52ca25715846790d84fa1d5d16e8a", }, "mac-arm64": { - bytes: 99_221_129, - sha256: "77da14e75d7f2568e6f7898d3df7cdc6faac74b15e903b2c9d486ebb6ca9b929", + bytes: 103_901_293, + sha256: "1f848385611f2e58122e68969489712661880fc9a92c9dd20428099f9998ba7b", }, "mac-x64": { - bytes: 104_748_425, - sha256: "a54292aaacbb77f76f6ef47558e7c51ab884044e0adacca315567f83c060bcc4", + bytes: 109_387_968, + sha256: "ab4cc2949f88439e2241b2712c49268f24982f2d25af87960466cc22bdc2e6e1", }, win32: { - bytes: 114_295_943, - sha256: "56b30d2d6c35775ebf8dc3618680f6529e1c38c87f7feb28a16e9904273d51f7", + bytes: 119_029_359, + sha256: "37dc9a03f37fd79637fec9f26042bf32f04cfe5b83749d980eb1e7d1a429d47c", }, win64: { - bytes: 120_822_223, - sha256: "3ac2561f02d9d87aadc0399d00b9002d718a4c365624fa67db9e7bfaf6b1a568", + bytes: 126_214_071, + sha256: "41c6aa97046d05d015b817a95f7b1512c3a1a41b18364f8da46478ae520400b1", }, } as const; @@ -126,11 +126,7 @@ export class PreviewBrowserUnsupportedError extends Schema.TaggedError const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"; export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-headless-shell"; -/** - * Modelled on the profile Ubuntu ships for Google Chrome; `unconfined` adds - * nothing beyond `userns`. - */ +/** Match Ubuntu's Chrome profile; unconfined adds nothing beyond userns. */ export const APPARMOR_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox. abi , include @@ -31,10 +28,7 @@ profile t3-chrome-headless-shell /**/tools/chrome-headless-shell/*/*/chrome-head } `; -/** - * Ubuntu 24.04 and Debian 13 renamed some with a `t64` suffix; setup installs - * whichever name the host's apt offers. - */ +/** Ubuntu 24.04 and Debian 13 use some t64 library names; choose names available through apt. */ export const DEBIAN_PACKAGES: ReadonlyArray> = [ ["libnss3"], ["libglib2.0-0t64", "libglib2.0-0"], @@ -112,11 +106,7 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc }, ); -/** - * The browser's own `--version` decides whether any are missing; `ldd` only - * lists them after the real loader fails, because it can report libraries a - * host provides another way (NixOS's nix-ld). - */ +/** Detect missing libraries with --version; ldd can report misses that NixOS nix-ld provides. */ export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* ( executable: string, ) { diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2b33f3b37080..0a9118e7b6d8 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -96,7 +96,6 @@ function makeSession(frame?: ReturnType) { return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" }; } if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; - // Snapshots read password fields from their own isolated world in each frame. if (method === "Page.createIsolatedWorld") { return { executionContextId: (input as { worldName?: string }).worldName === "t3-preview" ? 1 : 7, @@ -379,6 +378,37 @@ beforeEach(() => { desktopConnections.length = 0; }); +it.live("preview_open reports its navigation timeout instead of the request budget", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + initialNavigation = async () => { + throw Object.assign(new Error("page.goto: Timeout 15000ms exceeded."), { + name: "TimeoutError", + }); + }; + + const error = yield* broker + .invoke({ + scope, + operation: "open", + input: { + url: "http://localhost:1337/", + reuseExistingTab: false, + show: false, + }, + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "PreviewAutomationTimeoutError", + timeoutMs: 15_000, + }); + expect(error.message).toBe("Preview automation open timed out after 15000ms."); + }), + ).pipe(Effect.provide(layer)), +); + it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => @@ -405,7 +435,6 @@ it.live.each(["desktop" as const, undefined])( expect(error.message).toContain("cannot control"); expect(yield* manager.list({ threadId: scope.thread.threadId })).toEqual(before); expect(contexts).toHaveLength(1); - // A rejected explicit target must not replace this session's working tab. const reused = yield* broker.invoke({ scope, operation: "open", @@ -761,7 +790,6 @@ it.live("a tab opened to a file the browser cannot show reports the file to down const page = contexts[0]!.page; const pdf = "https://example.com/paper.pdf"; const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true }; - // What Chromium reports when a navigation turns into a download. page.emit("requestfailed", { ...request, frame: () => page, diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 3544cd172a04..3176d0ad9682 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -333,11 +333,7 @@ interface ServerDownload { readonly completedAt: string; } -/** - * Operations an agent may run on any tab in its thread, even while a human - * drives it. Evaluate is not one: page script can change anything, so it needs - * the same control as clicking. - */ +/** Read-only operations remain available during human control; evaluate requires control because scripts can mutate the page. */ const READ_OPERATIONS: ReadonlySet = new Set([ "status", "snapshot", @@ -383,9 +379,7 @@ const modifiersOf = (message: Record) => { return Number.isInteger(value) && value >= 0 && value < 16 ? value : 0; }; -// Cmd shortcuts from Apple viewers are no editing shortcut for Linux or headless -// Chromium, so they carry the command. Ctrl already works natively on Linux, -// except copy and cut, which headless Chromium only runs as commands. +// Linux/headless Chromium needs commands for Cmd editing shortcuts and Ctrl copy/cut. const editingCommand = (key: string, modifiers: number) => { const modifier = modifiers & 0b0111; if (modifier !== 2 && modifier !== 4) return null; @@ -409,10 +403,7 @@ const editingCommand = (key: string, modifiers: number) => { const CLIPBOARD_TEXT_LIMIT = 1024 * 1024; const CLIPBOARD_WORLD = "t3-preview-clipboard"; -// Headless Chromium shares one clipboard between every context, so the copy -// a viewer's copy or cut key makes is read from this world instead of the -// clipboard. Page scripts cannot see the world, and only a copy made while a -// real key is handled counts. +// Chromium shares its clipboard across contexts; capture real copy/cut keys in a page-inaccessible world instead. const CLIPBOARD_SCRIPT = `(() => { let keying = false; let copied = ""; @@ -507,8 +498,7 @@ const make = Effect.gen(function* () { : null; const updateFileChooserInterception = (tab: ServerTab) => { - // Native desktop views use the OS picker. Only agents and streamed controllers - // have an uploader that can answer an intercepted request. + // Only agents and streamed controllers can answer intercepted uploads; native desktop views use the OS picker. const intercept = !tab.desktop || tab.agentOperating || tab.control.controller !== null; if (intercept === tab.fileChooserIntercepted) return; tab.fileChooserIntercepted = intercept; @@ -824,9 +814,7 @@ const make = Effect.gen(function* () { timestamp: new Date().toISOString(), }); if (!isMainNavigation(request)) return; - // A navigation a newer one replaced leaves the status to the newer one. A - // popup's first request can predate the listener, so it is untracked and - // superseded by any tracked one. + // Newer navigations own status; untracked initial popup requests yield to any tracked navigation. const generation = navigationGenerations.get(request) ?? 0; if (generation !== tab.navigationGeneration) return; // An aborted navigation is usually a download; its `download` event settles the status. @@ -902,7 +890,9 @@ const make = Effect.gen(function* () { (desktop || adopted) && page.url() === snapshot.navStatus.url ? page.reload(options) : page.goto(snapshot.navStatus.url, options) - ).then(constVoid); + ).then(constVoid, (cause: unknown) => { + throw ServerBrowserPage.toOperationError(cause, NAVIGATION_TIMEOUT_MS); + }); if (tab.control.agentId !== null) tab.control.track(tab.initialNavigation); else void tab.initialNavigation.catch(constVoid); @@ -1032,7 +1022,6 @@ const make = Effect.gen(function* () { const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); if (!open || !controller) return; if (open.offeredTo !== controller.id) { - // A new controller gets a fresh id; the previous one can no longer answer. tab.fileChooser = { ...open, id: NodeCrypto.randomUUID(), offeredTo: controller.id }; if (open.offeredTo !== null) for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id }); @@ -1126,11 +1115,7 @@ const make = Effect.gen(function* () { }); }; - /** - * A frame in its own process, such as one with an `Origin-Agent-Cluster` - * header, is out of the page session's reach, so it records copies through - * its own session until that target closes. - */ + /** Out-of-process frames need their own copy-recording session until target closure. */ const recordFrameCopies = async (tab: ServerTab, frame: Frame) => { if (frame === tab.page.mainFrame() || tab.frameSessions.has(frame)) return; @@ -1458,7 +1443,7 @@ const make = Effect.gen(function* () { `Navigation to ${url} failed: ${ServerBrowserPage.parseNetError(message).description}`, ); } - throw cause; + throw ServerBrowserPage.toOperationError(cause, timeout); }); }; @@ -1776,8 +1761,7 @@ const make = Effect.gen(function* () { : "The requested server preview tab does not exist.", ); } - // Only an explicit tabId reuses a tab this session did not open; a tab - // it last read (such as the user's) is not taken over implicitly. + // Reusing another session's tab requires explicit tabId; reading it grants no implicit takeover. const existing = found && (request.tabIdExplicit || found.control.agentId === request.agentSessionId) ? found @@ -1890,8 +1874,7 @@ const make = Effect.gen(function* () { dropTab(tab, false); return {}; } - // A click can be waiting for its dialog. Resolve it outside the serial queue, - // with the same owner check, so the operation can finish and control can drain. + // Resolve dialogs outside the queue, with ownership checked, to unblock waiting clicks. if (request.operation === "dialog") { if (tab.control.controller !== null) throw new BrowserControlInterrupted("A human controls this tab.", "humanControl"); @@ -2475,9 +2458,7 @@ const make = Effect.gen(function* () { ), Effect.forkScoped, ); - // The page came back, for example after its DevTools closed. Reconnect now - // rather than on the next viewer or agent, or the tab's URL and title stop - // reaching every client in the meantime. + // Reconnect returned pages immediately so all clients keep receiving URL and title changes. yield* desktopChannel.attached.pipe( Stream.runForEach((key) => Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 85c8270be966..0e0f71d4e53c 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -28,9 +28,7 @@ const UA_PLATFORMS: Partial> = { win32: "Windows", }; -/** - * Sites block a user agent that disagrees with `Sec-CH-UA`, so both change together. - */ +/** Change user agent and Sec-CH-UA together; sites block mismatches. */ const chromeIdentity = ( headlessUserAgent: string, host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string }, diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 2491e6ac9a5f..b419d48e3659 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -149,7 +149,6 @@ describe("server browser element refs", () => { /typed-pass|saved-pass|role-pass|styled-pass|editable-pass|frame-pass|blank-pass/, ); expect(result.visibleText).not.toContain("editable-pass"); - // Page text masks the same values, so it can't be compared against the tree. expect(result.visibleText).toContain("Order ••••••••-2"); expect(tree).toMatch(/row "password: •+"/); expect(tree).toMatch(/textbox "Username" [^\n]*: maria$/m); @@ -173,7 +172,6 @@ describe("server browser element refs", () => { `); const cut = (await takeSnapshot()).visibleText; expect(cut.length).toBe(20_000); - // Masked before the cut, so no prefix of the value is left. expect(cut.slice(-8)).toBe("xxx•••••"); }); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 235502925839..129eaa8a89bb 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -40,7 +40,10 @@ export class ServerBrowserOperationError extends Error { } } -export const toOperationError = (cause: unknown): ServerBrowserOperationError => { +export const toOperationError = ( + cause: unknown, + timeoutMs?: number, +): ServerBrowserOperationError => { if (cause instanceof BrowserControlInterrupted) return new ServerBrowserOperationError( "PreviewAutomationControlInterruptedError", @@ -51,7 +54,11 @@ export const toOperationError = (cause: unknown): ServerBrowserOperationError => const message = cause instanceof Error ? cause.message : String(cause); const firstLine = message.split("\n")[0] ?? message; if (cause instanceof Error && cause.name === "TimeoutError") { - return new ServerBrowserOperationError("PreviewAutomationTimeoutError", firstLine); + return new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + firstLine, + timeoutMs === undefined ? undefined : { timeoutMs }, + ); } if ( /while parsing selector|Unknown engine|Unexpected token|strict mode violation/i.test(message) @@ -98,7 +105,6 @@ const targetLocator = ( ): Locator | null => { const selector = input.locator ?? input.selector; if (selector === undefined) return null; - // Ignore quoted/escaped CSS values when looking for a selector-engine boundary. const engines = selector.replace( /\\.|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, " ", @@ -200,11 +206,9 @@ const withoutTreeDataPayloads = (tree: string) => // The broker waits 15s for the host; a snapshot, retry included, answers well before. const SNAPSHOT_TIMEOUT_MS = 10_000; -// How long a frame in another process gets to answer; one that crashed never does. const FRAME_ANSWER_MS = 1_000; -// Values the page draws as dots (password fields, and text fields whose CSS -// draws dots), normalized like accessible text. Open shadow roots included. +// Include password and CSS-masked text in open shadow roots, normalized like accessible text. const DOTTED_SCRIPT = `(() => { const values = []; const visit = (root) => { @@ -237,10 +241,10 @@ const tooBusy = () => const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken."; -/** A frame in another process keeps one CDP session, so none pile up across snapshots. */ +/** Reuse one CDP session per out-of-process frame across snapshots. */ const frameSessions = new WeakMap>(); -/** The value, or undefined when it fails or takes longer than a frame may. */ +/** Returns undefined on failure or frame timeout. */ const answered = (promise: Promise) => { let timer: ReturnType | undefined; const late = new Promise((resolve) => { @@ -249,14 +253,7 @@ const answered = (promise: Promise) => { return Promise.race([promise.catch(() => undefined), late]).finally(() => clearTimeout(timer)); }; -/** - * Reads dotted values in isolated worlds, where page script can't change what `value` - * returns, through the CDP session of the process each frame renders in. A frame in - * another process that can't be attached, as in desktop tabs where only the page can, - * is read in the page's own world, where its script can change `value`. `read` gives - * each reader's values, or undefined for one that didn't answer, and fails when the - * page's own frames don't or frames came or went. - */ +/** Isolated worlds prevent spoofing; desktop out-of-process frames use page worlds. Missing own-frame reads or changed frames fail; other missing reads return undefined. */ const openReaders = async (page: Page, cdp: CDPSession) => { const frames = page.frames(); const owners = new Map( @@ -269,8 +266,7 @@ const openReaders = async (page: Page, cdp: CDPSession) => { return [frame, await session] as const; } catch (cause) { frameSessions.delete(frame); - // CdpRelay refuses all but the page. Any other refusal reads the frame through its - // parent's session, which fails the read if the frame isn't there. + // CdpRelay supports only the page; other refusals fall back to the parent session, failing if the frame is absent. const desktop = String(cause).includes("Only this preview tab's page can be attached"); return [frame, desktop ? null : undefined] as const; } @@ -342,10 +338,8 @@ const openReaders = async (page: Page, cdp: CDPSession) => { ]; return { readers, - /** Readers that didn't answer `before` aren't asked again. */ read: async (before?: ReadonlyArray) => { const now = page.frames(); - // A frame added since the readers opened has none. if (now.length !== frames.length || now.some((frame, i) => frame !== frames[i])) { throw pageChanged(); } @@ -359,12 +353,7 @@ const openReaders = async (page: Page, cdp: CDPSession) => { }; }; -/** - * Matches each value as the snapshot prints it: raw, as a JSON name, as a - * quoted value, and with '' for each quote inside a quoted key. A value under - * four characters only matches between non-word characters; inside other words - * it would mask unrelated text. - */ +/** Short values need word boundaries to avoid masking unrelated text. */ const dottedPattern = (values: ReadonlyArray) => { const sources = values .flatMap((value) => { @@ -387,8 +376,7 @@ const dottedPattern = (values: ReadonlyArray) => { if (sources.length === 0) return undefined; const pattern = new RegExp(sources.join("|"), "gu"); try { - // Compiles it now, for one-byte and two-byte text: one too large fails only when it - // first runs on each, naming every value. + // Force regex compilation for one-byte and two-byte strings now; size errors name every value. pattern.test(""); pattern.test("\u2022"); } catch { @@ -397,16 +385,11 @@ const dottedPattern = (values: ReadonlyArray) => { return pattern; }; -// `- role "name" [attribute]...: value`, the key single-quoted when it holds ": ". A name -// that starts and ends with "/" prints unquoted. Only Playwright's own attributes count, so -// text around a value in a name can't pass for one. +// Playwright single-quotes keys containing ': ' and leaves /.../ names unquoted. Recognize only its attributes so name text cannot spoof them. const TREE_LINE = /^(\s*- '?[^\s"':]+)( "(?:[^"\\]|\\.)*"| \/(?:.*?\/)?)?((?: \[(?:active|checked(?:=mixed)?|disabled|expanded|level=\d+|pressed(?:=mixed)?|selected|ref=(?:f\d+)?e\d+|cursor=pointer|box=-?\d+,-?\d+,\d+,\d+)\])*'?)(:.*)?$/; -/** - * Masks each value on a line, except one that only matches inside the role or attributes, - * so refs keep working. A "/.../" name has no closing mark, so a value can seem to cross it. - */ +/** Preserve roles and attributes so refs work; unquoted /.../ names can make matches cross boundaries. */ const maskTree = (tree: string, dotted: RegExp) => tree .split("\n") @@ -546,8 +529,7 @@ const capture = async ( const generation = state.generation; const { readers, read } = await openReaders(input.page, input.cdp); const before = await read(); - // Taken between the reads, so a value that changes during either fails the comparison. - // Awaited after the tree: a capture that fails has finished its tree before a retry starts. + // Bracket the tree with value reads to detect changes; await tree completion before retrying failed captures. const pageAndScreenshot = Promise.all([ input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< Pick< @@ -569,8 +551,7 @@ const capture = async ( if (after.some((values, i) => values && JSON.stringify(values) !== JSON.stringify(before[i]))) throw pageChanged(); const dotted = dottedPattern(after.flatMap((values) => values ?? [])); - // A frame that didn't answer both times, such as one that crashed, can only hold values the - // tree shows if the tree shows its content. + // Unread frames can expose values only if their content appears in the tree. const unread = readers.flatMap((reader, i) => (after[i] ? [] : reader.frames)); const filled = /^\s*- iframe (?:\[active\] )?\[ref=(\S+?)\].*:$/gm; for (const [, ref] of unread.length ? tree.matchAll(filled) : []) { @@ -595,8 +576,7 @@ const capture = async ( }); return { ...page, - // Text that holds a value would otherwise show where the tree masked it. Masked before - // the cut, so a value crossing it can't leave its start showing. + // Mask text before truncating so a value crossing the cut cannot leak its prefix. visibleText: mask(page.visibleText).slice(0, MAX_VISIBLE_TEXT_LENGTH), accessibilityTree, consoleEntries: [...input.consoleEntries], @@ -621,9 +601,7 @@ export const snapshot = async (input: SnapshotInput): Promise Date.now() < deadline && snapshotTurns.get(input.page) === turn && @@ -640,10 +618,7 @@ export const snapshot = async (input: SnapshotInput): Promise { (locator !== null || (await target.count()) > 0) && (await target.evaluate( (element) => { - // Like the desktop host: an enabled text control or contenteditable - // that actually takes focus. Anything else would swallow the text or - // send it to whichever field had focus before. + // Require an enabled, focused text target to avoid losing input or sending it to the previous field. const control = element as unknown as { readonly type?: string; readonly disabled?: boolean; @@ -792,8 +765,7 @@ export const drag = async ( const source = targetLocator(page, { locator: input.source })!; const target = targetLocator(page, { locator: input.target })!; await pointer(await targetPoint(page, source, {}, timeout), "move"); - // Observe the drag's outcome as it starts: a rejection nobody handles while the cursor - // moves exits the server. + // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. const dragFailure = source.dragTo(target, { timeout }).then( () => null, (error: unknown) => ({ error }), @@ -826,11 +798,7 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); }; -/** - * Runs an agent's expression, bounded by its request's deadline. Past it, the - * page's script is terminated and the call fails, so the tab's control queue - * frees for the next request instead of waiting on the script. - */ +/** Terminate expired page scripts so a hung expression cannot block the tab's control queue. */ export const evaluate = async ( cdp: CDPSession, input: PreviewAutomationEvaluateInput, diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 6b2d24802d24..d3f4781829a8 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -133,8 +133,7 @@ it.effect.each([ received.resolve(); }); yield* Effect.promise(() => received.promise); - // The ack follows every mutation on the socket, so this is also a barrier - // proving all preceding inputs were processed, without a timing sleep. + // The socket ack follows every mutation, providing a barrier without sleeps. yield* Effect.promise(() => acked.promise); expect(frames).toEqual([frame]); expect(viewports).toEqual([{ type: "viewport", width: 1280, height: 800 }]); @@ -215,7 +214,6 @@ it.effect("sends replies ahead of stale frames and only the newest frame after a ]); yield* Effect.promise(() => next.promise); expect(received).toEqual([1, "probe"]); - // Frames 2 and 3 were superseded before they could be sent. expect(released).toEqual([2, 3]); next = Promise.withResolvers(); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index cee60e2f9532..de02f32a1f77 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -110,10 +110,7 @@ export class SessionControl { return this.agentMayAct(agentId) && this.owner === null; } - /** - * A read that needs no control and does not queue, so an agent can look at - * a page while a human drives it without holding up the human's input. - */ + /** Read without control or queueing so agents can observe without delaying human input. */ observe(run: () => Promise) { this.assertOpen(); return run(); diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index d923a1bc7e9e..1e54691161ae 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -48,11 +48,7 @@ export interface ScopeState { readonly populated: boolean | undefined; } -/** - * Our pipe can close before systemd handles the kill, so a scope that is - * still stopping, is empty, or already counted an OOM kill is "stopping": - * systemd records `Result=oom-kill` for it in a moment. - */ +/** Pipes can close before systemd records oom-kill; stopping, empty or OOM-counted scopes remain pending. */ export function classifyScope(state: ScopeState): "oom-killed" | "gone" | "running" | "stopping" { if (state.result === "oom-kill") return "oom-killed"; if ( @@ -86,8 +82,7 @@ function parseCgroupCounter(text: string, key: string): number | undefined { return match === null ? undefined : Number(match[1]); } -// A stop sends SIGTERM, then SIGKILL after this long. systemd's 90 s default -// would outlast the wait for a previous server's agents below. +// systemd's default 90 s stop timeout would outlast our leftover-agent wait. const STOP_TIMEOUT_OPTION = "--property=TimeoutStopSec=10s"; const STOPPING_POLL_INTERVAL = "100 millis"; const STOPPING_POLL_ATTEMPTS = 20; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 73ff1b067e87..29547d2a23ad 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -19,10 +19,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -// Tests below write `#!/bin/sh` stubs into a real temp dir and hand that -// directory to a posix-mocked resolver as PATH. On a Windows host the temp -// path carries a drive letter, so the posix `:` split shatters it; there is -// no posix executable to find there anyway. +// Windows drive letters break mocked POSIX PATH splitting; these tests also require /bin/sh stubs. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; interface MockSpawnResult { diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 1260f2e3fda8..ff62b3ab5a11 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -66,12 +66,7 @@ interface TargetPathAndPosition { readonly column: Option.Option; } -/** - * Windows command shims (`code.cmd`) forward arguments through `%*`, which - * cmd.exe parses a second time after the first escaping layer is gone: a - * line break ends the command there, and a double quote closes the quoting - * that keeps `&` or `|` literal. Neither can occur in a Windows path. - */ +// cmd shims reparse %* after escaping is gone; newlines and quotes can expose shell operators. // oxlint-disable-next-line no-control-regex const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; @@ -83,9 +78,7 @@ const POWERSHELL_ARGUMENTS_PREFIX = [ "-EncodedCommand", ] as const; -// Windows PowerShell started with DETACHED_PROCESS exits 0 without running its -// command, so native Windows launches through it stay attached. WSL interop -// PowerShell starts from a Linux process and keeps the detached launch. +// Detached Windows PowerShell exits 0 without executing; only WSL interop keeps detached launches. const WINDOWS_POWERSHELL_DETACHED = false; const DETACHED_IGNORE_STDIO_OPTIONS = { @@ -196,10 +189,7 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -// File reveals from WSL resolve PowerShell through the interop PATH rather -// than the fixed /mnt/c mount: the automount root is configurable, and a -// PATH-resolved command keeps the advertised capability aligned with the -// availability check `launchEditor` performs before spawning. +// WSL automount roots vary; resolve PowerShell via PATH to match launchEditor's availability check. const WSL_POWERSHELL_COMMAND = "powershell.exe"; function shouldUseWindowsHostFromWsl( @@ -259,14 +249,8 @@ function fileManagerCommandForPlatform( } } -// A graphical session variable plus an executable `xdg-open` does not prove -// that opening a directory does anything: without an `inode/directory` MIME -// handler, `xdg-open` exits nonzero after the launcher has already detached, so -// the client would see a silent no-op. The probe carries its own timeout well -// inside the scan timeout `server.getConfig` applies to editor discovery: that -// outer timeout degrades to an empty editor list, so a hung `xdg-mime` (broken -// D-Bus or desktop session) must cost only the file manager, not every -// discovered editor. +// xdg-open needs an inode/directory handler or detached launches silently fail. +// Bound xdg-mime below discovery's timeout so a hung probe drops only the file manager. const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( @@ -319,10 +303,7 @@ const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManag }, ); -// WSL hosts prefer the Windows Explorer bridge, but interop can exist without -// `explorer.exe` on PATH (appendWindowsPath=false) or without a distro name -// while WSLg still provides a working Linux file manager, so they keep the -// `xdg-open` fallback instead of losing the editor entirely. +// WSL may lack Explorer on PATH or a distro name while WSLg still offers a Linux file manager. const resolveUsableFileManagerCommand = Effect.fn( "externalLauncher.resolveUsableFileManagerCommand", )(function* ( @@ -347,13 +328,8 @@ const resolveUsableFileManagerCommand = Effect.fn( return undefined; }); -// Reveal on Windows and WSL runs through PowerShell (see -// resolveFileManagerRevealLaunch), not the `explorer` command that gates the -// file-manager editor itself, so the capability must probe the executables the -// reveal actually spawns. Callers gate on file-manager availability first; -// the Linux "files" kind relies on that gate for the directory-handler probe, -// while the WSL fallback re-probes because its availability may have come -// from the Explorer bridge instead. +// Reveal uses PowerShell on Windows/WSL, so probe it separately from Explorer availability. +// Linux relies on the file-manager gate's handler probe; WSL re-probes because the gate may use Explorer. const fileManagerRevealKindForPlatform = Effect.fn( "externalLauncher.fileManagerRevealKindForPlatform", )(function* ( @@ -471,13 +447,7 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana }, ); -// Callers apply a timeout (`resolveAvailableEditorsForConfig`) and disconnect -// mid-connect; neither may cancel a scan other connects are waiting on, or -// throw away work a slow host (a busy server at startup, a long PATH) needs -// more than one connect to finish. Expiry uses the monotonic clock -// (Clock.monotonicTimeNanos), matching the command-resolution cache in -// @t3tools/shared/shell, so a backward wall-clock adjustment cannot keep an -// expired entry alive. +// Shared scans must survive caller timeouts/disconnects; monotonic expiry prevents clock rollback extending stale entries. const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { @@ -489,11 +459,7 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; - /** - * Only meaningful when `resolveAvailableEditors` includes "file-manager": - * on Linux that availability check also carries the directory-handler probe - * this capability relies on. - */ + /** Requires file-manager in resolveAvailableEditors; Linux relies on its directory-handler probe. */ readonly resolveFileManagerRevealKind: () => Effect.Effect; readonly launchBrowser: (target: string) => Effect.Effect; readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; @@ -556,8 +522,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] : platform === "win32" - ? // Explorer reads `/` as a switch, so `C:/repo` silently opens - // a default folder instead of the target. + ? // Explorer interprets / as a switch and silently opens a fallback folder. [normalizeWindowsFileManagerPath(input.cwd)] : [input.cwd], }; @@ -713,9 +678,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( onError: (cause: unknown) => ExternalLauncherError, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // The desktop app runs this server with ELECTRON_RUN_AS_NODE. A launched file - // manager or browser passes its environment on to everything started from it, - // so any Electron app opened from there would start as plain Node and exit. + // Inherited ELECTRON_RUN_AS_NODE would make Electron apps opened by this launcher run as Node and exit. const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment; const command = ChildProcess.make(launch.command, launch.args, { ...launch.options, @@ -789,7 +752,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -827,8 +790,7 @@ export const make = Effect.gen(function* () { Effect.interruptible, Effect.forkIn(scope), ); - // Claiming the cache entry and starting its scan must not be split by an - // interrupt, or the entry would wait on a scan that never runs. + // Claim and start atomically; interruption between them would leave an entry waiting on no scan. const acquireEditorDiscovery = Effect.gen(function* () { const nowNanos = yield* Clock.monotonicTimeNanos; const [scan, isNewScan] = yield* Ref.modify( diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 29d6dbd83659..97c761811a91 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -119,9 +119,7 @@ describe("runProcess", () => { it.effect("types spawn failures that the platform spawner throws synchronously", () => Effect.gen(function* () { - // A non-directory entry on PATH makes node's spawn throw ENOTDIR - // synchronously. The platform spawner calls it inside `Effect.callback`, - // so the throw arrives as a defect rather than a typed error. + // File-valued PATH entries make spawn throw ENOTDIR synchronously, arriving through Effect.callback as a defect. const defect = Object.assign(new Error("spawn jj ENOTDIR"), { code: "ENOTDIR" }); const spawner = makeSpawner(() => Effect.callback(() => { diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 05640ec968d3..c10c5cce032e 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -29,10 +29,7 @@ export interface ProcessRunInput { readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; - /** - * On timeout, return a synthetic timedOut result. - * Partial stdout/stderr are not preserved. - */ + /** Timed-out results discard partial stdout/stderr. */ readonly timeoutBehavior?: "error" | "timedOutResult" | undefined; } @@ -311,9 +308,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( ) .pipe( Effect.mapError(toSpawnError), - // Node's spawn can also throw synchronously, e.g. ENOTDIR when a PATH - // entry is a file. That arrives as a defect, which `mapError` never sees, - // so callers that recover from spawn failures would be bypassed. + // Synchronous spawn throws arrive as defects; recover them too so callers see typed spawn failures. Effect.catchDefect((cause) => Effect.fail(toSpawnError(cause))), ); @@ -393,7 +388,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } satisfies ProcessRunOutput; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("ProcessRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts index f9fe7e975419..84b318f0642c 100644 --- a/apps/server/src/project/AgentSessionJson.ts +++ b/apps/server/src/project/AgentSessionJson.ts @@ -47,9 +47,7 @@ export function createTranscriptJsonReader( selectPath: (path: JsonPath) => boolean, options?: { readonly maxDepth?: number }, ) { - // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted - // from its bundled types. Unlike parser(), it does not wrap tokens in an - // async generator; the file reader already supplies backpressure and UTF-8. + // 3.6.0 omits the synchronous tokenizer from types; parser() adds an unnecessary async generator. const { jsonParser } = StreamJson as typeof StreamJson & { jsonParser: ( options: ParserOptions, @@ -88,9 +86,7 @@ export function createTranscriptJsonReader( assembler.consume(token); } }; - // Forward actual selected keys instead of reconstructing them from path - // changes: adjacent duplicate keys have the same path but JSON.parse keeps - // the last value. Reconstructing paths can silently retain the first value. + // Preserve actual keys; adjacent duplicates share a path, but JSON.parse keeps the last value. const stack: Array<{ path: JsonPath; key: string | number | null; selected: boolean }> = []; let selectedValue = false; const startValue = () => { diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 29a55ec22ba1..baf0f27f9edb 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -745,7 +745,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); yield* fileSystem.writeFileString( path.join(submoduleGitDir, "config"), - '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + '[remote "origin"]\n\turl = ssh://gitlab.com/pingdotgg/libs/vendor.git\n', ); yield* fileSystem.writeFileString( path.join(submodule, ".git"), @@ -767,7 +767,10 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ).toEqual([ { path: submodule, - git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + git: { + remoteKey: "gitlab.com/pingdotgg/libs/vendor", + repository: "pingdotgg/libs/vendor", + }, }, { path: noRemote, git: { remoteKey: null, repository: null } }, { path: plain, git: null }, diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index f0fa0729bdb1..eb6e531c01b5 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -24,14 +24,10 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import { - normalizeGitRemoteUrl, - isBareGitConfig, - parseGitHubRepositoryNameWithOwnerFromRemoteUrl, - parseOriginUrlFromGitConfig, -} from "@t3tools/shared/git"; +import { isBareGitConfig, parseOriginUrlFromGitConfig } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; @@ -39,6 +35,7 @@ import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { literalProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, @@ -57,11 +54,7 @@ const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; -/** - * Large tool results (especially screenshots) can make an otherwise ordinary - * Codex transcript several GiB. Streaming field selection avoids allocating - * those payloads. - */ +// Screenshot-heavy transcripts can reach GiB; select fields while streaming to avoid allocating payloads. const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORTED_MESSAGES = 200; const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; @@ -442,9 +435,7 @@ function parseAgentSessionRecords( if (record.type === "event_msg" && record.payload?.type === "user_message") { const text = record.payload.message ?? ""; if (text.trim().length === 0) continue; - // Codex can write the same prompt as both a response item and an event. - // Remove only the matching response copy so mixed-format logs keep every - // distinct user message. + // Codex can duplicate prompts across formats; remove only matching response copies. for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message?.role === "assistant") break; @@ -630,11 +621,10 @@ function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdent ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - // Different project imports can arrive concurrently from multiple clients. - // Only one transcript may hold its selected-history budget at a time. + // Concurrent client imports must not hold multiple selected-history budgets. const importReadLock = yield* Semaphore.make(1); const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; @@ -726,8 +716,9 @@ export const make = Effect.gen(function* () { return { _tag: "Repository", git: { - remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), - repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + remoteKey: + originUrl === null ? null : sourceControlDefinitions.repositoryKeyFromRemote(originUrl), + repository: repositoryPathFromRemoteUrl(originUrl), }, } as const; }); @@ -1226,8 +1217,7 @@ export const make = Effect.gen(function* () { const realPath = yield* fileSystem .realPath(resolved) .pipe(Effect.orElseSucceed(() => resolved)); - // A symlink can point into the worktrees directory even when its own - // spelling doesn't; check again with links resolved. + // Symlinks can disguise excluded worktree paths; re-check their targets. if (isExcludedProjectPath(realPath)) { key = ""; } else { @@ -1264,8 +1254,7 @@ export const make = Effect.gen(function* () { : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); } - // Resolve persisted roots too. A project and a transcript can name - // different symlinks to the same directory. + // Resolve persisted roots too; transcripts may use different symlinks to the same directory. const importedProjects = yield* projectStore .listShells() .pipe( diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 449d7b8e00fc..1e1fab92562e 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -98,10 +98,7 @@ export class ManagedProjectFolders extends Context.Service< readonly text: string; }) => Effect.Effect, ScratchFolderError>; readonly namedProjectsRoot: string; - /** - * The folder is removed when the create fails or is cancelled before the - * project exists, never once another project owns it. - */ + /** Removes failed/cancelled creations' folders only while no project owns them. */ readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect< { readonly projectId: ProjectId; @@ -151,10 +148,7 @@ function escapeXml(value: string): string { .replaceAll('"', """); } -/** - * It lives at `assets/icon.svg`, a path ProjectFaviconResolver already checks, - * so every machine that clones the project shows the same icon. - */ +// ProjectFaviconResolver finds assets/icon.svg on every clone. function namedProjectIconSvg(name: string): string { const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); const initials = @@ -201,7 +195,6 @@ function describeCommitFailure(stderr: string): string { const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError); -// Spawning a `git` that is not on PATH fails with NotFound, on Windows too. const isGitMissing = (error: GitManagerServiceError) => error._tag === "GitManagerError" && isVcsProcessSpawnError(error.cause) && @@ -217,10 +210,7 @@ const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const crypto = yield* Crypto.Crypto; - /** - * Each folder is created without `recursive`, so creating it is the claim: of - * two racers for one name, one gets AlreadyExists and moves on to the next. - */ + // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. const claimFreeFolder = Effect.fnUntraced(function* ( folderFor: (attempt: number) => Effect.Effect, E>, onError: (folder: string, cause: PlatformError.PlatformError) => E, @@ -240,9 +230,7 @@ const make = Effect.gen(function* () { } }); - // Inside a checkout (a dev worktree's .t3, a dotfiles home) the folder would - // inherit the repo's git status and checkpoints, so Scratch is offered only - // when the data dir is outside any work tree. + // Scratch inside a checkout would inherit its git status and checkpoints; require a data dir outside work trees. const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL( gitWorkflow.isRepository(config.baseDir).pipe( Effect.catchIf(isGitMissing, () => Effect.succeed(false)), @@ -284,8 +272,7 @@ const make = Effect.gen(function* () { workspaceRoot, }) .pipe( - // bootstrap looks the root up before taking the workspace lock, so a - // racing create loses with a conflict that names the winner. + // bootstrap reads before locking; racing creates return the winner in a conflict. Effect.catchTags({ ProjectConflictError: (conflict) => Effect.succeed({ @@ -408,7 +395,7 @@ const make = Effect.gen(function* () { path.join(cwd, "assets", "icon.svg"), namedProjectIconSvg(name), ); - // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. + // Force explicit filenames so global ignore rules cannot drop them. yield* git.execute({ operation: "ManagedProjectFolders.add", cwd, @@ -453,11 +440,7 @@ const make = Effect.gen(function* () { workspaceRoot, }) .pipe( - // A rejected create leaves the folder unused, so remove it. An - // interrupt can land after the create is committed, and a conflict - // means another project owns the folder, so it stays then; the owner - // is checked again first, since deleting another project's files is - // never acceptable. + // Re-check ownership before deleting rejected creations; interruption after commit or conflicts can leave an owner. Effect.tapError((error) => error._tag === "ProjectConflictError" ? Effect.void diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 411d84fb3859..8cad093efff6 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -44,10 +44,7 @@ export class ProjectCloneTracker extends Context.Service< /** Interrupts a running clone and deletes the partial checkout. */ readonly cancel: (projectId: ProjectId) => Effect.Effect; readonly retry: (projectId: ProjectId) => Effect.Effect; - /** - * Stops tracking a project's clone, interrupting it if it still runs and - * removing an unfinished checkout. - */ + /** Stops tracking, interrupts any running clone and removes unfinished checkouts. */ readonly discard: (projectId: ProjectId) => Effect.Effect; readonly get: (projectId: ProjectId) => Effect.Effect; readonly stream: Stream.Stream>; @@ -91,7 +88,7 @@ interface TrackedClone { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const repositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const clones = yield* Ref.make(new Map()); @@ -100,8 +97,7 @@ export const make = Effect.gen(function* () { let sequence = 0; const cloneScope = yield* Scope.make("parallel"); yield* Effect.addFinalizer(() => Scope.close(cloneScope, Exit.void)); - // start/cancel/retry/discard mutate the same entry and the same directory; - // one at a time keeps a double-clicked Retry from racing two clones into it. + // Serialize actions on the shared entry and directory so retries cannot race clones. const actionLock = yield* Semaphore.make(1); const locked = (effect: Effect.Effect) => actionLock.withPermits(1)(effect); @@ -230,11 +226,8 @@ export const make = Effect.gen(function* () { }, Effect.scoped); /** - * Runs in its own fiber; the tracker records the outcome through `onExit`, - * which still runs when the fiber is interrupted (a `matchCause` handler - * would be skipped). Once git has finished the clone is marked done before - * the post-clone hook runs, so a late Cancel cannot tear down a complete - * checkout. + * `onExit` records outcomes even on interruption; `matchCause` would be skipped. + * Mark the clone done before the post-clone hook so a late Cancel cannot remove it. */ const runClone = (projectId: ProjectId, tracked: TrackedClone) => cloneWatchingForStall(projectId, tracked).pipe( @@ -313,14 +306,11 @@ export const make = Effect.gen(function* () { detail: "A clone into this destination is already in progress.", }); } - // Everything after the claim runs to completion even if the requesting - // connection drops: a claimed entry with no fiber could neither be - // cancelled nor retried. Any failure in here releases the claim. + // Finish or release the claim despite disconnects; a claim without a fiber cannot be cancelled or retried. yield* Effect.uninterruptible( Effect.gen(function* () { yield* clearRetention(input.projectId); - // The entry is registered before the project exists so a - // thread.create or project.delete racing this call already sees it. + // Register before creation so racing thread.create and project.delete see the clone. yield* hooks.createProject({ projectId: input.projectId, title: input.title, @@ -348,8 +338,7 @@ export const make = Effect.gen(function* () { const current = yield* Ref.get(clones); const tracked = current.get(projectId); if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) return false; - // Uninterruptible past this point: a client that disconnects mid-cancel - // must not leave a dead fiber behind a "running" snapshot. + // Finish cancellation despite disconnects to avoid dead fibers behind running snapshots. yield* Effect.uninterruptible( Effect.gen(function* () { yield* Fiber.interrupt(tracked.fiber!); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 1592204816fe..cbee7275b123 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -19,9 +19,7 @@ const DEFAULT_CACHE_CAPACITY = 512; const DEFAULT_MAX_PENDING = 512; const DEFAULT_CONCURRENCY = 4; const DEFAULT_SUCCESS_TTL = Duration.minutes(1); -// A favicon scan checks about 20 files. Shell reads (and PR sync through them) ask for every -// linked project about once a minute, and icons rarely change, so a resolved favicon is kept -// longer than the identity, which is only a hit on RepositoryIdentityResolver's own cache. +// Frequent shell reads scan ~20 files per project; icons change rarely, so cache longer than identities. const DEFAULT_FAVICON_TTL = Duration.minutes(15); const DEFAULT_FAILURE_TTL = Duration.seconds(5); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index e42cb5fbe919..dfae0d5fbe1b 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -71,9 +71,7 @@ const ICON_SOURCE_FILES = [ "src/index.html", ] as const; -// The tag pattern is anchored on `]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i; @@ -235,11 +233,8 @@ export const make = Effect.gen(function* () { return yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); }); - // Starts every probe at once and returns the first hit in list order. A miss - // costs one round of filesystem latency instead of one per probe, which - // matters when the disk is slow. Fibers are joined in order, so a hit returns - // without waiting for lower-ranked probes (the scope interrupts them), and a - // failure surfaces only when no earlier probe found a file. + // Probe concurrently but join in list order so lower-ranked probes cannot delay a hit. + // A failure surfaces only if no earlier probe found a file; scope exit interrupts remaining probes. const firstInOrder = Effect.fn("ProjectFaviconResolver.firstInOrder")(function* ( items: ReadonlyArray, probe: (item: A) => Effect.Effect, diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 42db582be4d6..89e2ccaf24fe 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -277,8 +277,7 @@ export const make = Effect.gen(function* () { `Command ${command.commandId} was already used by ${receipt.commandType} for ${receipt.projectId}.`, ); } - // A retried command re-plans against the state it already produced, so its - // first receipt, not the new plan, decides the outcome. + // Retries re-plan against their own effects; the first receipt determines the outcome. if (receipt.status === "accepted") return; const rejection = Option.getOrUndefined(decodeProjectCommandRejection(receipt.error)); switch (rejection?._tag) { @@ -493,11 +492,7 @@ export const make = Effect.gen(function* () { ); }); - // A machine-disabled provider instance that a deleted project's override - // once turned on must not stay probed and alive forever. Nothing else - // removes the entry, so deletion is where it goes; best-effort because a - // settings write failure should not block the deletion it is cleaning up - // after, and a server crash before a replay never revisits this project. + // Best-effort cleanup releases disabled instances kept alive by overrides; crashes have no replay to revisit deletion. const clearProjectSettingsOverrides = (projectId: ProjectId) => serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe( Effect.asVoid, @@ -512,8 +507,7 @@ export const make = Effect.gen(function* () { const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( function* (input) { const { projectId } = input; - // A deleted row still reaches commit, so a retried command id replays its - // receipt and any other command id is rejected as not found. + // Read deleted rows so retries replay receipts and other command ids fail as not found. const existing = yield* readRow(projectId, { includeDeleted: true }); if (Option.isNone(existing)) { return yield* new ProjectNotFoundError({ projectId }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 6799be186d1a..da8bb9a6c56a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -33,9 +33,7 @@ export interface ProjectSetupScriptRunnerResultStarted { readonly terminalId: string; readonly cwd: string; readonly async: boolean; - /** - * The exit code is null when the terminal exited or was closed before the sentinel arrived. - */ + /** Null exit code means the terminal exited or closed before the sentinel. */ readonly completion?: Effect.Effect; } @@ -114,11 +112,8 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} -/** @public Service construction is part of the canonical Effect module API. */ -/** - * Each run gets its own random token so script output cannot spoof completion, and the sentinel - * pattern is built per run from it. - */ +/** @public */ +// Random per-run tokens prevent script output from spoofing completion. const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__"; const OUTPUT_LINE_MAX_LENGTH = 400; const PARTIAL_LINE_MAX_LENGTH = 4_096; @@ -158,12 +153,7 @@ function resolveCompletionShell( return "posix"; } -/** - * The command runs inside a block and the block closes on its own line, so a trailing `# comment` - * or a heredoc terminator in the command cannot swallow the sentinel. The shell reads the whole - * block before running any of it, so a script that reads stdin cannot consume the sentinel line - * either. - */ +// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -180,7 +170,7 @@ function wrapCommandForCompletion( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const terminalManager = yield* TerminalManager.TerminalManager; @@ -203,8 +193,7 @@ export const make = Effect.gen(function* () { }) { const startedAtMs = yield* Clock.currentTimeMillis; const done = yield* Deferred.make(); - // The shell redraws its prompt just after the sentinel. Closing before - // that would read the redraw as new activity and keep an idle shell. + // Wait for the prompt redraw; closing earlier mistakes it for activity and preserves an idle shell. const promptReturned = yield* Deferred.make(); let lineBuffer = ""; let settled = false; @@ -246,13 +235,10 @@ export const make = Effect.gen(function* () { } if (event.type === "output") { lineBuffer += event.data; - // A bare carriage return is how installers redraw a progress line in - // place; each redraw becomes a short line of its own instead of being - // glued into one long one. + // Bare CR redraws progress in place; split redraws instead of growing one line. const lines = lineBuffer.split(/\r\n|\r|\n/); lineBuffer = lines.pop() ?? ""; - // A script that never prints a newline must not grow this forever. - // The sentinel is always on its own line, so keeping the tail is safe. + // Bound newline-free output; the sentinel has its own line, so retaining the tail is safe. if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); } @@ -347,8 +333,7 @@ export const make = Effect.gen(function* () { } as const; } - // A thread settles again after it is resumed, and an earlier settle shell - // may still be busy; typing into it would feed its foreground program. + // Earlier settle shells may still run foreground programs; do not type resumed settles into them. const terminalId = input.preferredTerminalId ?? (trigger === "settle" @@ -360,8 +345,7 @@ export const make = Effect.gen(function* () { project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }), - // Setup can run before a client attaches. Truecolor probes in tools such - // as Vite+ wait for terminal replies that nobody can send at that point. + // Before client attachment, truecolor probes wait for terminal replies nobody can send. COLORTERM: "", }; const observe = input.observeCompletion; diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 6fc57297a40e..7cd1cc82d6fe 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -35,8 +35,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); - // Native realpath, since git reports the long form of a directory the - // temp dir may name by its 8.3 short form on Windows. + // Native realpath matches git's long paths when Windows temp dirs use 8.3 names. const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath); const resolvedCwd = NodeFS.realpathSync.native(cwd); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 0ec948b485fc..00254ee86781 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,9 +1,5 @@ import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -13,13 +9,12 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { explicitBareRepositoryEnv } from "../vcs/VcsProcess.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -// Background sweeps resolve every project each minute. A long TTL keeps them -// from spawning git each time. Clone, publish, and PR discovery (after a turn -// and before it saves links) resolve with `refresh: true`. +// Minute-by-minute sweeps need cached hits; clone, publish and PR discovery force refresh. const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15); const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); @@ -42,8 +37,7 @@ export class RepositoryIdentityResolver extends Context.Service< } >()("t3/project/RepositoryIdentityResolver") {} -// Local remotes (GitButler's `gb-local` is `.`) name a path on this machine, -// not a repository, so unrelated repos would share one identity. +// Local paths such as GitButler's gb-local=. would give unrelated repos the same identity. function isLocalRemoteUrl(url: string): boolean { if (/^file:/i.test(url)) return true; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false; @@ -102,7 +96,7 @@ function buildRepositoryOrigin( canonicalKey: string, ): RepositoryIdentity["origin"] { if (!originUrl) return undefined; - const originKey = normalizeGitRemoteUrl(originUrl); + const originKey = sourceControlDefinitions.repositoryKeyFromRemote(originUrl); if (originKey === canonicalKey) return undefined; const displayName = repositoryPathOf(originKey, originUrl); return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) }; @@ -114,8 +108,8 @@ function buildRepositoryIdentity(input: { readonly originUrl: string | undefined; readonly rootPath: string; }): RepositoryIdentity { - const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); + const canonicalKey = sourceControlDefinitions.repositoryKeyFromRemote(input.remoteUrl); + const sourceControlProvider = sourceControlDefinitions.recognizeRemote(input.remoteUrl); const repositoryPath = repositoryPathOf(canonicalKey, input.remoteUrl); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; @@ -148,8 +142,7 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. const processRunner = yield* ProcessRunner.ProcessRunner; const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); - // git is a real executable on every platform — no cmd.exe shell mode, which - // would split paths containing spaces during cmd's re-tokenization. + // git is always a real executable; cmd shell mode would split spaced paths on re-tokenization. const topLevelResult = yield* processRunner .run({ command: "git", diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts index b4335cb5660c..1cc008d07cc8 100644 --- a/apps/server/src/project/T3ProjectFileLoader.ts +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -28,10 +28,7 @@ export class T3ProjectFileLoadError extends Schema.TaggedError Effect.Effect>; } >()("t3/project/T3ProjectFileLoader") {} @@ -46,7 +43,7 @@ const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) => }), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts index 0bdf7af4d2b0..ff5580961d97 100644 --- a/apps/server/src/project/WorktreeSetupTracker.ts +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -56,10 +56,7 @@ export class WorktreeSetupTracker extends Context.Service< phase: "done" | "failed" | "cancelled", error?: string | null, ) => Effect.Effect; - /** - * Called right before the turn is dispatched so a late cancel cannot roll - * back a thread whose agent has already started. - */ + /** Call just before dispatch so late cancellation cannot roll back a started agent's thread. */ readonly markUncancellable: (threadId: ThreadId) => Effect.Effect; readonly cancel: (threadId: ThreadId) => Effect.Effect; readonly get: (threadId: ThreadId) => Effect.Effect; @@ -103,8 +100,7 @@ export const make = Effect.gen(function* () { readonly snapshot: WorktreeSetupSnapshot | null; }>(); const retentionFibers = new Map>(); - // Sequences keep increasing across setups of the same thread so a stream - // opened during a previous setup still accepts the next one's first snapshot. + // Keep sequences increasing across setups so existing subscribers accept the next setup's first snapshot. const lastSequenceByThread = new Map(); const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) => @@ -142,8 +138,7 @@ export const make = Effect.gen(function* () { return next; }).pipe( Effect.andThen(publish(threadId, null)), - // A subscriber that outlives retention sees `null` here and accepts any - // sequence after it, so the counter can start over for this thread. + // After retention, subscribers see null and accept any sequence; the counter can reset. Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), ); @@ -300,19 +295,14 @@ export const make = Effect.gen(function* () { const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); - /** - * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only ever - * holds the newest snapshot, so a chatty setup script cannot grow the server - * heap. - */ + // One-slot sliding mailboxes bound slow WebSockets to the newest setup snapshot. const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) => Stream.callback( (mailbox) => Effect.gen(function* () { const subscription = yield* PubSub.subscribe(changes); const initial = yield* get(threadId); - // Changes published between subscribing and reading `initial` are - // already folded into it. Drop them so the client never steps back. + // The initial snapshot already includes queued changes; drop them to avoid stale replay. let lastSequence = initial?.sequence ?? -1; Queue.offerUnsafe(mailbox, initial); yield* Stream.fromSubscription(subscription).pipe( diff --git a/apps/server/src/project/gitCloneProgress.ts b/apps/server/src/project/gitCloneProgress.ts index 1a65c11d3cfe..f1d75b532bc7 100644 --- a/apps/server/src/project/gitCloneProgress.ts +++ b/apps/server/src/project/gitCloneProgress.ts @@ -18,10 +18,7 @@ const STAGE_PREFIXES: ReadonlyArray = [ const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; -/** - * Git redraws each counter with a bare `\r`, so callers hand over each redraw - * as its own line. - */ +/** Pass each bare-CR progress redraw as a separate line. */ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { const trimmed = line.trim(); const stageEntry = STAGE_PREFIXES.find(([pattern]) => pattern.test(trimmed)); diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index dfcfb2fecdc8..af156a61663f 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -8,6 +8,7 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as AcpErrors from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; @@ -62,6 +63,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( const authenticated = yield* Deferred.make(); const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); + const authenticateCalled = yield* Deferred.make(); const events: string[] = []; let receiveAuthorizationUrl: | ((url: string) => Effect.Effect) @@ -92,6 +94,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( start: () => Effect.gen(function* () { events.push("authenticate"); + yield* Deferred.succeed(authenticateCalled, undefined); if (options.interactive !== false && input.onAuthorizationUrl) { for (const url of options.authorizationUrls ?? [authorizationUrl]) { yield* input.onAuthorizationUrl(url); @@ -129,6 +132,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( authenticated, discovered, closed, + authenticateCalled, events, catalog: () => catalog, forwarded: () => forwarded, @@ -254,6 +258,38 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect("tells the user when the agent cannot reach Google", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ interactive: false }); + yield* harness.auth.controller.start(owner); + yield* Deferred.await(harness.authenticateCalled); + + yield* TestClock.adjust("30 seconds"); + const stalled = yield* harness.auth.controller.subscribe(owner).pipe( + Stream.filter((state) => state.message?.includes("Broken IPv6") === true), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(stalled.phase, "starting"); + + yield* TestClock.adjust("270 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "could not reach Google"); + }), + ); + + it.effect("keeps the expired message once the Google link was shown", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + + yield* TestClock.adjust("300 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.equal(failed.message, "Google sign-in expired. Start sign-in again."); + }), + ); + it.effect("accepts direct local or cached completion without a callback RPC", () => Effect.gen(function* () { const harness = yield* makeHarness({ interactive: false }); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index c97aa949aab8..a2026faa78d2 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -31,6 +31,13 @@ import { import type { ProviderAuthController } from "./ProviderAuthService.ts"; const AUTH_TIMEOUT_MS = 300_000; +// A healthy agent shows the Google link or finishes within seconds. Its bundled Python has no +// IPv4 fallback race, so a host with broken IPv6 waits about two minutes per Google request. +const NO_RESPONSE_HINT_MS = 30_000; +const NO_RESPONSE_MESSAGE = + "Antigravity has not reached Google after 30 seconds. Check this machine's network connection. Broken IPv6 is a common cause."; +const UNREACHABLE_MESSAGE = + "Antigravity could not reach Google. Check this machine's network connection, then start sign-in again."; const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; const isSetupError = Schema.is(ProviderSetupError); const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); @@ -264,6 +271,17 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < const runSignIn = Effect.fn("AntigravityAuth.runSignIn")( function* (flow: AuthFlow, stopSessions: Effect.Effect) { yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses)); + yield* Effect.sleep(NO_RESPONSE_HINT_MS).pipe( + Effect.andThen( + lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow || flow.state.phase !== "starting") return; + yield* publishFlow(flow, { ...flow.state, message: NO_RESPONSE_MESSAGE }); + }), + ), + ), + Effect.forkScoped, + ); const runtime = yield* options.makeRuntime({ onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url), }); @@ -288,7 +306,14 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < Effect.timeoutOrElse({ duration: AUTH_TIMEOUT_MS, orElse: () => - Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), + Effect.fail( + setupError( + "start", + flow.state.phase === "starting" + ? UNREACHABLE_MESSAGE + : "Google sign-in expired. Start sign-in again.", + ), + ), }), Effect.exit, Effect.flatMap((result) => finishFlow(flow, result)), diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index e7bcbe7553f6..363eb4b78858 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -43,10 +43,7 @@ const zipFixtures = { "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=", }; -// The installation checks POSIX exec bits off the real filesystem unless the -// platform is win32, so a linux platform mock cannot pass on NTFS. Default to -// the host and let the fixture names follow; the suite is about install -// mechanics, which are the same on every platform. +// Linux platform mocks cannot enforce POSIX exec bits on NTFS; use the host platform. const hostPlatform: NodeJS.Platform = HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; const completeArchive = Buffer.from( @@ -300,8 +297,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { it.effect("accepts an encoded Content-Length when the body is compressed", () => Effect.gen(function* () { - // dl.google.com gzips the archive and reports the encoded size. The decoded - // stream is still checked byte for byte and by hash. + // dl.google.com reports the gzip-encoded size; byte and hash checks use the decoded stream. const { installation, validations } = yield* makeHarness({ contentLength: completeArchive.byteLength - 1_000, contentEncoding: "gzip", @@ -573,8 +569,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }), ); - // Real posix executables in a real temp dir, resolved by a linux-mocked - // PATH walk; a Windows temp path cannot be split on `:`. + // The mocked Linux PATH walk needs POSIX paths; Windows temp paths contain colons. it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "honors explicit paths and reports invalid overrides without falling back", () => diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 3b7cb6f1e18a..3d590abda0bd 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -373,8 +373,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma profileDirectory, platform, baseEnv: environment, - // The profile is scoped, so it cleans up the unpack; a shallow - // root keeps it under Windows' path limit. + // A shallow scoped profile cleans up unpacked files within Windows' path limit. tempDirectory: profileDirectory, }); const runtime = yield* makeAntigravityAcpRuntime({ @@ -390,9 +389,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma ? ChildProcessSpawner.make( Effect.fnUntraced(function* (command) { const child = yield* spawner.spawn(command); - // Stop PyInstaller's tree before ACP closes its pipes. Once - // the launcher exits, taskkill cannot reach its children, - // which keep the executable and unpacked DLLs locked. + // Stop PyInstaller's tree before ACP closes pipes; after launcher exit, taskkill cannot reach children holding DLLs open. yield* Scope.addFinalizer( validationScope, child.kill({ forceKillAfter: "1 second" }).pipe(Effect.orDie), @@ -407,8 +404,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma if ( initialized.agentInfo?.name !== "antigravity-acp" || initialized.agentInfo.version !== expectedVersion || - // Antigravity 1.1.1 can report 2 with the legacy ACP response shape. - // The ACP client chooses the session wire format from that shape. + // Antigravity 1.1.1 may report protocol 2 with legacy ACP; choose wire format from the response shape. (initialized.protocolVersion !== 1 && initialized.protocolVersion !== 2) || initialized.agentCapabilities?.loadSession !== true || !initialized.agentCapabilities.sessionCapabilities?.resume || @@ -464,7 +460,6 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma { flag: "wx", mode: 0o600 }, ); yield* fs.rename(pointerPath, activePath); - // The pointer commits the install. Later temp cleanup cannot undo it. yield* SubscriptionRef.update( state, (current) => @@ -531,9 +526,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma const response = yield* http .execute(HttpClientRequest.get(asset.url)) .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); - // dl.google.com gzips the zip when the client accepts it, so - // `content-length` is the encoded size. The decoded stream is still - // checked against the pinned byte count and hash below. + // dl.google.com may gzip the zip: content-length is encoded, while byte/hash checks use decoded data. const contentLength = response.headers["content-length"]; const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase(); const identityBody = contentEncoding === undefined || contentEncoding === "identity"; diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 1c1f3d9a56d9..c6809c357713 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -160,9 +160,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func draft: initialDraft, authRevision: 0, }); - // Skills the driver discovered on disk per workspace. Session callbacks - // rewrite the workspace entry with native commands and must keep these, or - // the registry drops the suggestions and never re-reads the workspace. + // Preserve disk-discovered skills when callbacks replace workspace commands; the registry will not rescan them. const discoveredSkills = new Map(); const getSnapshot = SubscriptionRef.get(metadata).pipe( Effect.flatMap((state) => options.stampIdentity(state.draft)), @@ -211,8 +209,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func draft: { ...draft, installed: !missingInstallation, - // Custom launchers have no static version; their lightweight probe - // must not erase the version learned from a real ACP handshake. + // Lightweight custom-launcher probes lack versions; preserve the real handshake's version. version: version === "unknown" ? draft.version : version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", checkedAt: updatedAt, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 139a0ed39075..b60bda50b5f1 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -173,11 +173,7 @@ function apiProviderAuthMetadata( return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; } -/** - * The capability probe resolves for a logged-out CLI, so a completed probe only proves Claude - * Code started. `tokenSource: "none"` is the CLI reporting it found no token at all, and is the - * one shape that disproves authentication. - */ +/** Probe completion only proves Claude started. Only tokenSource: "none" disproves auth. */ function claudeAuthStatus( capabilities: Pick< ClaudeCapabilitiesProbe, @@ -188,8 +184,7 @@ function claudeAuthStatus( return "authenticated"; } if (capabilities.tokenSource !== "none") return "authenticated"; - // An `ANTHROPIC_API_KEY` install reports no token source but is authenticated - // all the same, so the key and account fields still get a say. + // API keys can authenticate with tokenSource: none; also check key and account fields. const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none"; return hasApiKey || capabilities.email || capabilities.subscriptionType ? "authenticated" @@ -199,14 +194,8 @@ function claudeAuthStatus( /** `tokenSource` the SDK reports when the CLI took its token from the environment. */ const CLAUDE_OAUTH_TOKEN_SOURCE = "CLAUDE_CODE_OAUTH_TOKEN"; -/** - * Whether Anthropic has stopped accepting the token this instance was given. - * - * Gated on the CLI having actually chosen the environment token, so an install - * that authenticates some other way is never judged by a variable it ignores. - * Anything short of an outright rejection answers `false`: an offline machine - * should show a stale status, not a wrong one. - */ +// Gated on the CLI having chosen the environment token, so another auth mode is never judged by +// it. Anything short of an outright rejection answers false: offline should look stale, not wrong. const isConfiguredClaudeTokenRejected = Effect.fn("isConfiguredClaudeTokenRejected")(function* ( tokenSource: string | undefined, environment: NodeJS.ProcessEnv, @@ -221,8 +210,7 @@ const isConfiguredClaudeTokenRejected = Effect.fn("isConfiguredClaudeTokenReject return (yield* verifyClaudeOAuthToken(token)) === "rejected"; }); -// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the Bedrock -// backend and runs the `awsAuthRefresh` credential hook before returning account info. +// Bedrock initialization includes backend startup and awsAuthRefresh, taking longer than first-party auth. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; // Native CLI startup on Windows can exceed the shared probe deadline. const CLAUDE_VERSION_CHECK_TIMEOUT_MS = 20_000; @@ -245,23 +233,17 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { pathToClaudeCodeExecutable: input.executablePath, abortController: input.abortController, settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], - // The probe keeps filesystem setting sources for slash-command discovery, - // but must not run the user's hooks: it fires every few minutes, so - // SessionStart hooks would run on every health check. + // Keep settings for slash-command discovery, but disable hooks so health checks do not run SessionStart repeatedly. settings: { disableAllHooks: true }, allowedTools: [], - // Ignore MCP definitions from every filesystem setting source above. The - // SDK combines this empty explicit map with --strict-mcp-config, which - // Claude refuses next to a managed-mcp.json that already excludes them. + // Exclude filesystem MCP servers; omit strict mode when managed-mcp.json already excludes them and forbids the flag. mcpServers: {}, ...(input.managedMcpConfig ? {} : { strictMcpConfig: true }), env: { ...input.environment, - // Connected claude.ai MCP servers are discovered outside filesystem - // config; disable them independently for this health check. + // Disable claude.ai MCP separately; it is discovered outside filesystem config. ENABLE_CLAUDEAI_MCP_SERVERS: "false", - // Skipping it also avoids Claude spawning a Windows `tasklist | findstr` process tree on - // every periodic refresh. + // Skipping this avoids a Windows tasklist | findstr process tree on every refresh. FORCE_CODE_TERMINAL: undefined, CLAUDE_CODE_AUTO_CONNECT_IDE: "0", CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", @@ -282,18 +264,10 @@ export type ClaudeCapabilitiesProbe = { readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; readonly apiKeySource: string | undefined; - /** - * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth - * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) - * the subscription/token fields are absent and auth is external AWS creds. - */ + /** OAuth applies only to firstParty; Bedrock uses external AWS credentials and omits subscription/token fields. */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; - /** - * Subscription windows from the SDK's `get_usage` control request, or - * `undefined` when the request itself failed. Absent windows on an - * otherwise successful response mean the account has none (API key). - */ + /** Undefined means get_usage failed; absent windows on success mean no subscription windows, as with API keys. */ readonly usage?: Pick; readonly usageCheckedAt?: string; }; @@ -370,12 +344,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { }); } -/** - * We pass a never-yielding AsyncIterable as the prompt so that no user message is ever written - * to the subprocess stdin. This means the Claude Code subprocess completes its local - * initialization IPC (returning account info and slash commands) but never starts an API - * request to Anthropic. - */ +/** A never-yielding prompt allows local initialization IPC without sending an Anthropic request. */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, @@ -392,9 +361,7 @@ const probeClaudeCapabilities = ( const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ - // Never yield — we only need initialization data, not a conversation. - // This prevents any prompt from reaching the Anthropic API. - // oxlint-disable-next-line require-yield + // oxlint-disable-next-line require-yield -- never yields, so no prompt reaches the API prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index 43b60d21fffa..89cb3de6ed55 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -23,8 +23,7 @@ function addKnownCount(finalCount: unknown, earlierCounts: ReadonlyArray, earlierSegmentUsage: ReadonlyArray, diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 20cc69cac391..1051f7281439 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -306,8 +306,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* for (const session of "sessions" in saved ? saved.sessions : [saved]) { if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue; connections.add(session.clientId); - // Subjects are client-scoped. Use verified email only for counting locally, - // never export it or merge the underlying profiles. + // Client-scoped subjects need verified email for local counting only; never export email or merge profiles. const email = session.email?.trim().toLowerCase(); if (email) accounts.add(email); else unidentifiedConnectedConnectionCount++; @@ -460,8 +459,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* const savedRegistration = changingAccount ? undefined : registrations.profiles.find((profile) => profile.clientId === selectedClientId); - // Older development registrations used localhost, which cannot be changed on reauth. - // Register a 127.0.0.1 connection instead, preserving the verified account. + // Legacy localhost registrations cannot change on reauth; register 127.0.0.1 while preserving the verified account. const legacyCallback = savedRegistration?.redirectUri?.includes("//localhost:") === true; const registeredClientId = legacyCallback ? undefined : savedRegistration?.clientId; const selectedTokens = (yield* readSessions).sessions.find( @@ -640,8 +638,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* const expectedSubject = savedRegistration?.subject ?? (previous?.clientId === registeredClientId ? previous?.subject : undefined); - // Subjects are checked within the same registration. Legacy callback migration - // registers a new client, whose subject cannot be compared with the old client. + // Compare subjects only within a registration; legacy callback migration creates a new client. if (registeredClientId && expectedSubject && identity.subject !== expectedSubject) return yield* failure( "verify", diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 55758f7cc40e..d3067e51caaf 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -42,6 +42,27 @@ describe("buildCodexDeveloperInstructions", () => { }); }); +describe("Codex orchestration context", () => { + it("routes same-child follow-ups to t3_thread_send and independent reviews to delegate_task", () => { + const orchestration = Object.entries(buildCodexAdditionalContext({ model: "gpt-6.1-sol" })) + .filter(([key]) => key.startsWith("t3_code_orchestration_")) + .map(([, entry]) => entry.value) + .join("\n"); + + NodeAssert.ok( + orchestration.includes( + "For independent T3 delegated review rounds, call `delegate_task` again.", + ), + ); + NodeAssert.ok( + orchestration.includes( + "For a follow-up that needs the same child's conversation, use `t3_thread_send` on its `childThreadId`.", + ), + ); + NodeAssert.ok(!orchestration.includes("Do not use `t3_thread_send`")); + }); +}); + function runtimeInstructions(runtime: Parameters[0]) { return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 5c9f51e55230..a3ff122bcd22 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -7,11 +7,7 @@ import { T3_CODE_ORCHESTRATION_SECTIONS, } from "@t3tools/provider-core/server/orchestrationInstructions"; -/** - * Codex cuts the middle out of any `additionalContext` value over 1,000 tokens, - * which it counts as UTF-8 bytes divided by four. It does this silently, so - * every value built here has to fit. - */ +/** Codex silently truncates additionalContext values over 1,000 tokens, counted as UTF-8 bytes / 4. */ export const CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES = 4_000; const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices @@ -28,12 +24,7 @@ const normalizeAvailability = ( ): T3CodeToolAvailability => typeof availability === "boolean" ? { browser: availability, device: false } : availability; -/** - * Describing `preview_*` or `device_*` tools that aren't in the turn's tool list would be worse - * than saying nothing: the instructions actively steer the model away from Playwright, - * agent-browser, and raw simctl/adb, so leaving them in would talk it out of the only automation - * it still has. - */ +/** Instructions for unavailable tools would steer the model away from its remaining automation. */ const toolInstructions = (availability: boolean | T3CodeToolAvailability): string => { const tools = normalizeAvailability(availability); return [ @@ -199,18 +190,13 @@ export function buildCodexDeveloperInstructions(interactionMode: ProviderInterac : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; } -/** - * This must stay out of the collaboration mode: when the model catalog ships - * its own text for a mode, as newer models do, Codex uses that text and drops - * the client's `developer_instructions` entirely. - */ +/** Keep this outside collaboration mode; model-catalog mode text overrides client developer_instructions. */ export function buildCodexAdditionalContext( runtime: CodexRuntimeInfo, toolsAvailable: boolean | T3CodeToolAvailability = true, ): Record { const tools = toolInstructions(toolsAvailable); - // Separate keys keep each value under Codex's per-entry cap. Codex emits - // entries in key order, so the numbering preserves the authored order. + // Split values to fit per-entry caps; number keys because Codex emits them in key order. return { ...Object.fromEntries( T3_CODE_ORCHESTRATION_SECTIONS.map((section, index) => [ diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index e481f95e3c57..669cbc6c8e2f 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -597,8 +597,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function }), { flag: "wx", mode: 0o600 }, ); - // On Windows, Defender and kernel process rundown can briefly hold the - // verified codex.exe handle open right after `--version` exits. + // Windows Defender or process rundown may retain codex.exe handles briefly after --version exits. const renameRuntime = fs.rename(runtime, destination); yield* platform === "win32" ? renameRuntime.pipe(Effect.retry({ times: 10, schedule: Schedule.spaced("100 millis") })) @@ -741,9 +740,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ); yield* Effect.gen(function* () { - // Two servers can share a home, and writes below a staging directory do not - // refresh its mtime, so only directories far older than any real install - // (download is capped at 45 minutes) are treated as abandoned. + // Shared-home installs do not refresh parent mtimes; remove only staging directories far older than the 45-minute download cap. let canRemove = false; if (yield* fs.exists(managedDirectory)) { const entries = yield* fs @@ -762,8 +759,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function if (now - modifiedAt < STAGING_STALE_AFTER_MS) continue; yield* fs.remove(target, { recursive: true, force: true }).pipe(Effect.ignore); } - // The empty root itself stays: deleting it could race another server - // creating a staging directory inside. An unreadable root stays removable. + // Keep the empty root to avoid racing new staging directories; unreadable roots remain removable. const remaining = yield* fs.readDirectory(managedDirectory).pipe(Effect.option); canRemove = Option.isNone(remaining) || remaining.value.length > 0; } diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index d064dd708ad9..25de2e9ffe09 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -71,10 +71,7 @@ const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const; const CODEX_CLIENT_NAME = "T3 Code"; -// Codex's probe does a full handshake plus account, models, and skills requests, which can -// legitimately run past the shared 10s AUTH_PROBE_TIMEOUT_MS on a loaded or slow machine -// (most often reported on Windows). Give Codex its own longer budget instead of raising the -// timeout for every provider. +// Handshake plus account/models/skills requests can exceed the shared 10s probe budget. const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; const CODEX_DISCOVERY_MARGIN_MS = 250; @@ -440,12 +437,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio ); }); -/** - * App-server starts its catalog refresh before `initialize` names the client, - * so that first fetch goes out as the plain CLI, misses tiers like Sol - * Ultrafast, and overwrites the shared `models_cache.json`. Naming the - * originator at spawn closes that race. - */ +/** Set originator at spawn; the pre-initialize refresh otherwise overwrites shared cache with plain-CLI models. */ export const codexAppServerEnvironment = ( environment: NodeJS.ProcessEnv | undefined, ): NodeJS.ProcessEnv => ({ @@ -473,9 +465,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu readonly cwd: string; readonly environment?: NodeJS.ProcessEnv | undefined; }) { - // `~` is not shell-expanded when env vars are set via `child_process.spawn`, - // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip - // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand CODEX_HOME explicitly: spawn passes a literal ~ that Codex rejects. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory) : undefined; diff --git a/apps/server/src/provider/CodexTurnTokenUsage.ts b/apps/server/src/provider/CodexTurnTokenUsage.ts index 6f4959627eff..7b0f20a123ff 100644 --- a/apps/server/src/provider/CodexTurnTokenUsage.ts +++ b/apps/server/src/provider/CodexTurnTokenUsage.ts @@ -66,10 +66,7 @@ export function getCodexTurnAccumulator( return created; } -/** - * Codex reports a running `total` for the thread and `last`, the usage of the - * newest model response. - */ +/** total is thread-wide; last covers the newest model response. */ function codexTurnTokenUsageDelta( previous: CodexCumulativeTokenUsage | undefined, current: CodexCumulativeTokenUsage, @@ -104,8 +101,7 @@ export function accumulateCodexTurnTokenUsage( ): void { const current = codexTokenUsageBreakdown(usage.total); if (state.activeTurnId !== turnId) { - // The total is thread-wide, so every update moves the baseline. A late - // update for a finished turn is not counted toward the live turn. + // Every thread-wide total updates the baseline; late finished-turn updates do not count toward the live turn. state.baseline = current; return; } diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 0f42271c62d2..fe974dee763f 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -215,11 +215,9 @@ export const AntigravityDriver: ProviderDriver< : cause, ), ); - // Each process unpacks into its own directory that dies with the - // runtime scope, after the child is killed. A shared directory would - // let one session's teardown delete files a sibling still reads. - // Removal is best effort: a handle can outlive the kill on Windows, - // and the sweep on the next driver start reclaims what is left. + // Use one directory per process; shared cleanup could delete files another session still reads. + // Remove after the child dies, best-effort on Windows where handles may outlive the kill; + // the next driver start sweeps leftovers. const runtimeTempDirectory = yield* Effect.acquireRelease( fileSystem.makeTempDirectory({ directory: profile.tempDirectory, prefix: "run-" }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 7b25e24d9081..19e9d8d293f4 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -7,12 +7,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { parse as parseYamlDocument } from "yaml"; -/** - * The home directory the agent expands `~` against, matching Python's - * `os.path.expanduser` in the launch environment T3 hands the process: - * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` - * elsewhere. - */ +/** Match Python os.path.expanduser: USERPROFILE, then HOMEDRIVE+HOMEPATH on Windows; HOME elsewhere. */ export function resolveAntigravityUserHome( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, @@ -28,12 +23,7 @@ export function resolveAntigravityUserHome( return environment.HOME || home; } -/** - * The agent resolves both under `GEMINI_HOME`, which T3 points at a private - * profile, so the profile links these back to the user's `~/.gemini`. - * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a - * project directory. - */ +/** Link private GEMINI_HOME skills to ~/.gemini; ~/.agents/skills is ignored because .agents/skills is project-only. */ export function antigravityUserSkillDirectories( path: Path.Path, geminiHome: string, @@ -147,9 +137,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( return bytes.toString("utf8"); }); -/** - * Read failures remain typed so workspace snapshots do not cache partial results. - */ +/** Keep read errors typed so workspace snapshots cannot cache partial results. */ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { readonly cwd: string; readonly userHome: string; diff --git a/apps/server/src/provider/Drivers/ClaudeCredential.ts b/apps/server/src/provider/Drivers/ClaudeCredential.ts index 2a1943c16c3b..7d115ff3518e 100644 --- a/apps/server/src/provider/Drivers/ClaudeCredential.ts +++ b/apps/server/src/provider/Drivers/ClaudeCredential.ts @@ -1,21 +1,7 @@ /** - * ClaudeCredential: liveness check for a configured Claude OAuth token. - * - * The capability probe reports which credential the CLI *found*, never whether - * Anthropic still honours it. An expired or revoked setup token still reads as - * `tokenSource: "CLAUDE_CODE_OAUTH_TOKEN"`, so Settings would keep claiming the - * provider is authenticated right up until a turn fails. This module closes - * that gap with one cheap authenticated GET. - * - * `GET /v1/models` is used because it is the only first-party endpoint that - * accepts the token, costs no message quota, and answers the only question - * worth asking: does Anthropic still accept this credential. - * - * Only a value that looks like an Anthropic credential is ever sent. Anything - * else is a question for someone other than Anthropic, and a `401` earned by an - * unresolved secret reference or a placeholder would blame the token for a - * problem that is not the token's. - * + * Liveness check for a configured Claude OAuth token via one cheap `GET /v1/models`: the probe + * only reports which credential was found. Only Anthropic-shaped values are sent, so a + * placeholder never causes a misleading 401. * @module provider/Drivers/ClaudeCredential */ import * as Duration from "effect/Duration"; @@ -34,12 +20,8 @@ export type ClaudeCredentialVerdict = "live" | "rejected" | "unknown"; const ANTHROPIC_MODELS_URL = "https://api.anthropic.com/v1/models?limit=1"; /** - * `sk-ant-` is shared by Anthropic setup tokens (`sk-ant-oat01-`) and API keys - * (`sk-ant-api03-`). The tail is spelled out because the prefix alone accepts - * `sk-ant-oat01-${MY_TOKEN}`, and a placeholder that happens to carry the - * prefix is still a placeholder. Anything outside the character set key - * material is written with is treated as not a credential, which costs at most - * an unknown verdict and never a wrong one. + * `sk-ant-` covers setup tokens (`sk-ant-oat01-`) and API keys (`sk-ant-api03-`). The tail is + * spelled out so placeholders like `sk-ant-oat01-${MY_TOKEN}` are not treated as credentials. */ const ANTHROPIC_CREDENTIAL = /^sk-ant-[A-Za-z0-9._~+/=-]+$/; const ANTHROPIC_VERSION_HEADER = "2023-06-01"; diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index e2bf2b99eefa..5e4cbb4db6ef 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -7,20 +7,13 @@ import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -/** - * Windows launcher-script extensions that Node cannot spawn without a shell - * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore - * cannot use as `pathToClaudeCodeExecutable`. - */ +/** Node 20.12+ cannot spawn Windows launcher scripts without a shell; the SDK cannot use these as executables. */ const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ".ps1"]); export const isWindowsClaudeLauncherShimPath = (filePath: string): boolean => WINDOWS_SHIM_EXTENSIONS.has(NodePath.win32.extname(filePath).toLowerCase()); -/** - * Newer package versions ship a native `bin/claude.exe`; older versions only ship `cli.js`, - * which the SDK runs with a JavaScript runtime. - */ +/** New packages provide bin/claude.exe; older packages need a JavaScript runtime for cli.js. */ const NPM_PACKAGE_ENTRY_CANDIDATES = [ ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], @@ -43,12 +36,7 @@ export const ClaudeExecutableFileCheck = Context.Reference( }, ); -/** - * The SDK spawns the given path without a shell and without Windows PATH / PATHEXT resolution, - * so a bare command name like `claude` fails with "native binary not found" and an npm - * `claude.cmd` shim fails with `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, - * which can fall back to `shell: true`; the SDK offers no such escape hatch. - */ +/** On Windows the SDK skips shell and PATH/PATHEXT lookup: bare names and .cmd shims fail. */ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return { const platform = yield* HostProcess.Platform; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 4a9fd19f3004..1dbfdd1bc06a 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -40,13 +40,9 @@ const decodeSettingsEnv = Schema.decodeUnknownEffect( ); /** - * Claude Code copies the `env` of each settings file over its own environment - * at startup, so a user's `OTEL_RESOURCE_ATTRIBUTES` there replaces the one T3 - * Code launched it with and drops `t3code.launched`. Flag settings outrank those - * files, so this returns the `env` to pass there: the value the files would - * leave in effect, plus the attribute. It returns nothing when no file sets the - * variable, the file value already carries the attribute, or the launch - * environment holds a value the user chose, such as `false`. + * Claude Code copies each settings file's `env` over its own environment, so a user's + * `OTEL_RESOURCE_ATTRIBUTES` there drops `t3code.launched`. Returns the flag-settings `env` that + * restores it, or nothing when no file sets it, it already carries it, or the user chose a value. */ export const claudeSettingsLaunchedEnv = Effect.fn("claudeSettingsLaunchedEnv")(function* ( cwd: string | null, @@ -106,21 +102,12 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function const resolvedHomePath = yield* resolveClaudeHomePath(config); return { ...resolvedBaseEnv, - // Overriding HOME also relocates the macOS login keychain lookup - // ($HOME/Library/Keychains), so the spawned CLI can't find its stored - // OAuth credentials and reports "Not logged in". CLAUDE_CONFIG_DIR points - // Claude Code at its config dir directly while leaving HOME (and the - // keychain) intact. + // Changing HOME hides macOS Keychain credentials; use CLAUDE_CONFIG_DIR to keep login access. CLAUDE_CONFIG_DIR: resolvedHomePath, }; }); -/** - * While it exists Claude refuses `--strict-mcp-config` and exits before - * initializing, but it already ignores user, project and `--mcp-config` servers - * on its own, so callers that pass the flag for isolation can drop it. Paths - * match the ones Claude Code checks. - */ +/** managed-mcp.json excludes other MCP config and forbids --strict-mcp-config; omit the flag when present. */ export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; const platform = yield* HostProcess.Platform; @@ -154,10 +141,7 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC }, ); -/** - * Describe the spawned CLI's environment separately from the login command so - * paths remain literal on every shell, including relative inherited values. - */ +/** Separate spawn env from login commands to preserve literal paths, including relative inherited values. */ export const claudeSignedOutMessage = (input: { readonly configDir: string | undefined; readonly cwd: string; diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 2524dcbb6c7d..534308e9c2f4 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -1,18 +1,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; /** - * Claude Code's only user-side invocation is a text block whose first character - * is `/`: the harness expands `/name args` into the SKILL.md body, and every - * character after the name (newlines included) arrives as `ARGUMENTS`. Only one - * skill expands per message; a second `/x` becomes argument text - * ([#87113](https://github.com/anthropics/claude-code/issues/87113)). + * Claude expands one skill per message only when `/name` starts the text block. + * Everything after the name, including newlines or a second `/x`, becomes `ARGUMENTS`. + * https://github.com/anthropics/claude-code/issues/87113 */ -/** - * Same token shape the composer and timeline chips recognise - * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a - * dispatched skill are always the same set. - */ +/** Match packages/shared/src/composerInlineTokens.ts so displayed chips and dispatched skills accept the same tokens. */ const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 62c322cabf12..e331254c1171 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -504,9 +504,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); - // A skill with no frontmatter falls back to its directory name; a skill - // whose frontmatter fails to parse is skipped entirely (Claude Code - // won't load it either). + // Claude falls back to directory names without frontmatter, but skips malformed frontmatter. assert.deepEqual( skills.map((skill) => skill.name), ["no-frontmatter"], @@ -561,8 +559,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const workspace = path.join(tempDir, "workspace"); yield* fs.makeDirectory(workspace, { recursive: true }); - // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own - // cwd (the workspace), so discovery must do the same. + // The CLI resolves relative CLAUDE_CONFIG_DIR against workspace cwd. yield* writeSkill( path.join(workspace, "relative-config", "skills"), "relative-skill", @@ -684,8 +681,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), ); } - // Verified against the CLI: with an unknown string or a boolean in the - // map, the valid "off" sibling is ignored too and every skill runs. + // Claude ignores the entire override map when any value is an unknown string or boolean. yield* fs.writeFileString( path.join(configDir, "settings.json"), '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }', diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index ff2628761181..56c2ff19bebd 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -33,11 +33,7 @@ type SkillFrontmatter = readonly userInvocable?: boolean; }; -/** - * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`, - * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and - * numbers. - */ +/** Claude accepts YAML 1.1 booleans that this YAML 1.2 parser leaves as strings or numbers. */ function parseFrontmatterBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "number") { @@ -71,8 +67,7 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { try { parsed = parseYamlDocument(frontmatter); } catch { - // Claude Code accepts plain scalars containing `: `. Repair only those, - // leaving comments and YAML structure for the full-document parser. + // Repair Claude's accepted ': ' scalars without disturbing comments or YAML structure. const repaired = frontmatter.replace( /^([\w-]+:[ \t]*)([^\r\n]*)/gm, (line, prefix: string, value: string) => { @@ -424,11 +419,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct if (homePath.length > 0) { return path.resolve(expandHomePath(homePath, home)); } - // No tilde expansion here: the spawned CLI receives this env var verbatim - // (env vars are never shell-expanded), so a literal `~` must stay literal - // for discovery to scan the same directory the runtime would. A relative - // value is resolved against the workspace cwd — the subprocess's own cwd — - // for the same reason. + // Match the spawned CLI: preserve a literal ~ and resolve relative config against workspace cwd. const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (environmentConfigDir.length > 0) { return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); @@ -436,11 +427,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); -/** - * Roots are listed highest precedence first and the first hit for a name wins, - * matching Claude Code: verified against the CLI with the same skill name in - * both scopes, the user copy is the one that runs. - */ +/** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, cwd?: string, @@ -455,7 +442,6 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* environment ?? process.env, ); - // Use the session's argument parser so discovery sees the same --add-dir value. const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index db8f42faea7f..023942177a91 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -254,12 +254,8 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(restored.runtimePaths?.shadowHomePath).toContain( `providers/codex/${instanceId}/shadow`, ); - // The fixture app-server cannot start, so the workspace scan fails - // instead of passing the machine snapshot off as the workspace's skills. const scan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe(Effect.exit); expect(Exit.isFailure(scan)).toBe(true); - // The account is still signed in, so a runtime that cannot start is a - // failed scan too, not the machine snapshot's inventory. failAcquire = true; const runtimeScan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe( Effect.exit, @@ -299,8 +295,6 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(after.installed).toBe(true); expect(after.models).toEqual([]); expect(Option.isNone(yield* store.get)).toBe(true); - // Signed out, there is no runtime to scan with, so the machine - // snapshot's empty inventory stands instead of a failed scan. const signedOutScan = yield* instance.snapshotForCwd!(serverConfig.stateDir); expect(signedOutScan.skills).toEqual([]); }).pipe( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 5462b13867b7..2d078cf32594 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -64,12 +64,7 @@ function isCodexStandaloneCommandPath(commandPath: string): boolean { return normalizeCommandPath(commandPath).includes("/packages/standalone/"); } -/** - * `codex update` replaces the standalone tree under `CODEX_HOME`. That tree - * lives in the shared home even when an auth-overlay shadow home is in use - * (the overlay only carries auth and a few local entries), so the updater - * runs against `sharedHomePath` rather than the instance's effective home. - */ +/** `codex update` owns the shared home's standalone tree; auth-overlay homes do not carry it. */ function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, @@ -286,13 +281,9 @@ export const CodexDriver: ProviderDriver< ), ); - // Redemption spends something on the user's account. It serialises on - // the account (instances sharing a Codex home share the credit), keeps - // one idempotency key until Codex reports an outcome, and is bounded so - // a hung app-server cannot hold the account lock. - // Keyed on the directory holding auth.json: an auth-overlay instance has - // its own account under `effectiveHomePath`, while plain instances share - // the common home. The continuation key would conflate the two. + // Redemption spends account credit; serialize by the auth.json directory, keep one idempotency key + // until the outcome, and bound the wait so a hung app-server cannot hold the account lock. + // Auth overlays use effectiveHomePath; the continuation key would conflate them with shared homes. const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; const consumeResetCredit: NonNullable = () => resetCreditCoordinator diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index b0bb7df4e2d4..f175a0fdd00a 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -31,8 +31,7 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); // Finder maintains metadata separately for each directory, including shadow homes. const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); -// Codex creates these runtime locks itself when it starts in a shadow home before the shared -// home has one. They guard shared state, so a local copy is replaced with the shared link. +// Shadow homes may create local runtime locks first; replace them with shared links because they guard shared state. const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set(["mcp-oauth-locks", ".sqlite-maintenance.lock"]); function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 1f4a238fa34c..f9728f9a2497 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -235,8 +235,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu ); yield* runtime.auth.controller.subscribe("managed-codex-snapshot").pipe( Stream.filter((state) => ["idle", "succeeded", "failed", "cancelled"].includes(state.phase)), - // Sign-out closes the scope of an in-flight probe, which fails that refresh - // with an interrupt. Keep listening so the sign-out's own idle still refreshes. + // Sign-out interrupts in-flight probes; keep listening for its subsequent idle refresh. Stream.runForEach(() => snapshot.refresh.pipe(Effect.ignoreCause({ log: true }))), Effect.forkScoped, ); @@ -301,10 +300,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu enabled ? resolveRuntime.pipe( Effect.matchEffect({ - // Signed out or not set up, there is nothing to scan with and the - // machine snapshot's empty inventory says as much. Otherwise the - // runtime failed for a signed-in account (a token refresh or - // reconnect) or before the first check, and the scan failed. + // Signed-out snapshots have an empty inventory; a signed-in runtime failure must fail the scan. onFailure: (cause) => snapshot.getSnapshot.pipe( Effect.filterOrFail( @@ -327,8 +323,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu }), Effect.scoped, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - // Fail rather than return the machine snapshot: the registry - // publishes any returned snapshot as this workspace's inventory. + // Fail instead of returning machine inventory, which the registry would publish as workspace inventory. Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 26db5d8d46f3..5f288198eee0 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -170,11 +170,7 @@ class AntigravityDatabaseDecodeError extends Schema.TaggedError(query, [after, pageRows]) .pipe(Effect.withTracerEnabled(false)); for (const row of rows) { - // Skipped here rather than in SQL, so a statement never scans past - // more than one page of steps that have no metadata. + // Filter outside SQL to bound scans to one page, including rows without metadata. if (step && row.data === null) continue; const idx = row.idx; if (typeof idx !== "number") { @@ -327,12 +320,7 @@ interface CachedDatabase { readonly candidates: readonly UsageCandidate[]; } -/** - * Parsed databases keyed by canonical path. An entry is reused only while both - * the database and its `-wal` sidecar keep the same size, mtime and ctime: new - * rows land in the WAL without touching the main file until a checkpoint, and - * ctime moves on any content write even when mtime is restored. - */ +/** Fingerprint canonical DB/WAL paths by size, mtime and ctime; WAL holds new rows, and ctime catches restored mtimes. */ export const makeAntigravityUsageCache = () => new Map(); export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* ( diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index 1db2197a53e5..a9ebb05a2e1e 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -49,8 +49,7 @@ describe("parseClaudeLine", () => { }); it("gives every content block of one message the same dedupe key", () => { - // T3 Code writes one record per content block, each repeating the parent - // message's full usage. Summing them would overcount ~2.4x on real data. + // Content-block records repeat full parent usage; summing them overcounts. const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index 11f16631bca8..d9ee4f4a04ff 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -444,7 +444,7 @@ describe("parseCodexLine", () => { // A forked/subagent rollout opens with the parent's history copied in and // every line re-stamped to the fork instant, then the ancestors' session - // metas. Counting those again multiplied usage ~1.85x on real data (#5758). + // metas. Counting those again multiplied usage ~1.85x on real data. describe("forked rollouts", () => { const meta = (overrides: { id: string; diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index d3fd0680e31c..38615eb2cb0c 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -98,12 +98,7 @@ export function initialCodexScanState(): CodexScanState { }; } -/** - * A forked or subagent rollout opens with the parent's full history copied in, - * every line re-stamped to the fork instant. Those copies are written in one - * synchronous burst (observed gaps 0-40ms), while the child's first genuine - * usage event only lands after a real model turn (observed 5s+). - */ +/** Forks copy and restamp parent history in a burst (0-40ms gaps); genuine child usage follows a model turn (5s+ observed). */ const FORK_COPY_MAX_GAP_MS = 1000; function isForkedSessionMeta(payload: Record): boolean { @@ -141,9 +136,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const payloadType = payloadRecord["type"]; if (record["type"] === "session_meta") { - // Only the first meta describes this file's own session. A forked rollout - // repeats the ancestors' metas right after it; letting those through would - // reassign every subsequent record to an ancestor session. + // Only the first meta identifies this session; copied ancestor metas would misattribute later records. if (state.sawSessionMeta) return null; state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; @@ -302,10 +295,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | }; } -/** - * Codex omits the field when no tier was requested, which bills as standard, as - * do `default` and `standard`. `fast` is accepted as an alias of `priority`. - */ +/** Missing, default and standard tiers bill as standard; fast aliases priority. */ function codexSpeed(serviceTier: unknown): UsageSpeed { if (serviceTier === "priority" || serviceTier === "fast") return "fast"; if (serviceTier === "ultrafast") return "ultrafast"; @@ -332,8 +322,7 @@ export const codexUsageFormat: TranscriptUsageFormat = { info: { last_token_usage: true, total_token_usage: true }, }, }, - // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no - // usage, but they carry the model, tier and session the reducer needs. + // Metadata lines carry model, tier and session even without usage. usageMarkers: ['"token_count"', '"turn_context"', '"thread_settings_applied"', '"session_meta"'], parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts index 6af7290b5f62..93061b35913e 100644 --- a/apps/server/src/provider/EventNdjsonLogger.ts +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -/** - * Native and canonical views share batching, rotation, and retention state so - * they cannot race while appending to the same thread-scoped file. - */ +/** Native and canonical views share write state to avoid races on the same thread file. */ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -203,8 +200,7 @@ export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unk const nested = Reflect.get(event, "event"); const envelope = typeof nested === "object" && nested !== null ? nested : event; - // Decoded frames carry the same information as raw frames without another - // copy of every token delta. + // Decoded frames preserve raw-frame information without duplicating every token delta. if (Reflect.get(envelope, "stage") === "raw") return false; const decodedPayload = Reflect.get(envelope, "payload"); const nativeEvent = diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 373d9773280a..a6945eb902a6 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -304,7 +304,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { ); // An expired access token prints the logged-out banner, then the CLI refreshes it in the - // same run, so the next `grok models` reports the login (#14285). + // same run, so the next `grok models` reports the login. it.effect("reports authenticated when `grok models` logs in after refreshing a token", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 27cd700e1a20..3cebfb22c791 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -26,8 +26,7 @@ const MODEL_MANIFEST_URL = const MANIFEST_TTL_MS = 60 * 60 * 1000; -/** Minimum gap between fetch attempts after a failure, so an offline server - * does not pay a network timeout on every provider check. */ +/** Back off failed fetches so offline health checks do not each wait on the network. */ const MANIFEST_RETRY_MS = 5 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; @@ -63,11 +62,7 @@ const ManifestProviderCatalog = Schema.Struct({ const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), - /** - * A release bundles its manifest, and a disk cache of an older edit must not - * outrank it. Optional so older remote files still decode; they count as - * older than any dated bundle. - */ + /** Older disk caches must not override bundled manifests; absent dates rank below any dated bundle. */ updatedAt: Schema.optional(Schema.String), compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), @@ -169,9 +164,7 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; readonly refresh: Effect.Effect; readonly forceRefresh: Effect.Effect; - /** Drivers call this from - * provider checks: the fetch is process-shared state, so it must survive - * the teardown of whichever instance happened to trigger it. */ + /** Process-shared fetches must survive the triggering instance's teardown. */ readonly refreshInBackground: Effect.Effect; } >()("t3/provider/ModelManifest") {} @@ -199,9 +192,7 @@ export const make = Effect.gen(function* () { let lastAttemptMs: number | null = null; const refreshSemaphore = yield* Semaphore.make(1); - // `Effect.cached` makes concurrent first readers await the same disk load - // rather than racing a "loaded" flag. Only `refreshed` takes the fetch - // semaphore; `current` must never wait behind an in-flight network refresh. + // Only refresh takes the fetch semaphore; current must not wait on network I/O. const ensureDiskCacheLoaded = yield* Effect.cached( Effect.gen(function* () { const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( @@ -209,8 +200,6 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); if (fromDisk === null) return; - // Comparing edit dates, not fetch time, keeps this independent of when - // the cache was written relative to the release. if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) { return; } @@ -222,9 +211,7 @@ export const make = Effect.gen(function* () { const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; - // A timestamp in the future means the wall clock moved backwards (the - // disk cache crosses restarts, so monotonic time cannot cover it). Treat - // it as expired: the refetch rewrites both timestamps and self-heals. + // Expire future timestamps after clock rollback; disk caches cross restarts, ruling out monotonic time. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; @@ -243,8 +230,7 @@ export const make = Effect.gen(function* () { Effect.timeout(FETCH_TIMEOUT_MS), Effect.catchCause(() => Effect.succeed(null)), ); - // A CDN can still serve an earlier edit after a release. Apply the same - // freshness rule as the disk cache so it cannot undo bundled version gates. + // Reject stale CDN edits just like stale disk caches to preserve bundled version gates. if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) { return manifest; } diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index 5a730c5baf8d..52c37e4d9ea5 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -88,9 +88,7 @@ export const makeProviderAuthService = Effect.gen(function* () { return instance.auth; }); - // Native sessions may still belong to the previous provider after the - // selected model changes. Read session bindings, not the selected model, - // when invalidating credentials for sign-in or sign-out. + // Invalidate credentials using session bindings; model selection may already point to another provider. const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( instanceId: ProviderInstanceId, binding: ProviderAuthController["credentialBinding"], diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 48711552b43c..0315cb8dfd83 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -285,8 +285,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { [claudeId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: true, - // Packaged desktop builds start the server in $HOME, where some - // `claude` wrappers refuse to run. + // Packaged desktop starts in $HOME, where some claude wrappers refuse to run. environment: [{ name: "T3_CLAUDE_REQUIRED_CWD", value: stateDir, sensitive: false }], config: makeClaudeConfig({ enabled: true, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index a07d962089f7..418572339b49 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -33,33 +33,20 @@ export class ProviderInstanceRegistry extends Context.Service< ) => Effect.Effect; readonly listInstances: Effect.Effect>; readonly listUnavailable: Effect.Effect>; - /** - * The configured environment of every instance `rebuildInstanceWhen` can act on: live ones - * and ones whose last build failed. Lets a caller resolve every external secret before a - * batch of rebuilds instead of once per rebuild. - */ + /** Every instance `rebuildInstanceWhen` can act on, so secrets resolve once per batch of rebuilds. */ readonly listEnvironments: Effect.Effect< ReadonlyMap >; /** - * Rebuilds one instance from the configuration it already has when `shouldRebuild` accepts - * it, for inputs a driver reads at create time that change without the settings file - * changing (a credential in an external secret store), which `reconcile`'s diff cannot see. - * The instance keeps its settings-author position, an unknown id is a no-op, and sessions - * already using the old instance keep it. Resolves to whether a rebuild happened. + * Rebuilds an instance from its current config when `shouldRebuild` accepts it, for create-time + * inputs `reconcile` cannot see (an external secret). Unknown ids are a no-op; running sessions + * keep the old instance. Resolves to whether a rebuild happened. */ readonly rebuildInstanceWhen: ( instanceId: ProviderInstanceId, shouldRebuild: (entry: ProviderInstanceConfig) => boolean, ) => Effect.Effect; - /** - * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the stream starts - * running, forking a consumer via `Stream.runForEach(...).pipe(Effect.forkScoped)` races - * the next publish — the forked fiber may not have subscribed yet when the publish lands. - * Hot-reload consumers that must not miss a publish should use `subscribeChanges` below - * instead, which acquires the subscription synchronously in the caller's fiber before the - * consumer loop is forked. - */ + /** streamChanges subscribes on consumption; use subscribeChanges before forking to avoid missing the next publish. */ readonly streamChanges: Stream.Stream; readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } @@ -84,13 +71,9 @@ function withoutKey( } /** - * Reinstate a rebuilt instance where it used to sit. Map iteration order is - * settings-author order all the way out to the provider list, so appending a - * rebuilt instance would shuffle the UI every time a secret is refreshed. - * - * An instance that is not in `previousEntries` was never live to begin with - * (a rebuild that recovers a previously unavailable instance), so it has no - * position to return to and goes last until the next settings change. + * Reinstate a rebuilt instance at its old position: map order is settings-author order, so + * appending would shuffle the UI on every refresh. An instance absent from `previousEntries` + * goes last until the next settings change. */ function withInstanceAt( previousEntries: ReadonlyMap, @@ -114,11 +97,7 @@ interface RegistryState { const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => Equal.equals(a, b); -/** - * An explicit false on either the envelope or the raw config blob wins (most - * restrictive) — old settings files can carry both flags with conflicting - * values, and a user's disable must never be silently undone. - */ +/** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); if (entry.enabled === false || rawConfigEnabled === false) { @@ -273,9 +252,7 @@ const makeReconcile = (input: { nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), ); - // 1. Close scopes for instances that disappeared or whose config - // changed. Do this BEFORE creating replacements so ids map 1-to-1 - // to live scopes at all times. + // Close changed/removed scopes before replacements to preserve one live scope per id. const removedIds: Array = []; const replacedIds = new Set(); for (const [instanceId, live] of previousEntries) { @@ -443,25 +420,17 @@ export const makeProviderInstanceRegistry = Effect.fn( return false; } - // Everything except the build itself is bookkeeping over refs, and it - // is uninterruptible so the instance is never half-moved. Only the - // build is interruptible, because it is the part that can take - // minutes waiting on a secret store. - // - // Remembering the envelope comes first: an interrupt anywhere after - // this point still leaves the instance retryable on the next - // refresh, which is the only recovery path it has left. + // Only the build is interruptible (it can wait minutes on a secret store); the bookkeeping is + // uninterruptible so the instance is never half-moved. Remembering the envelope comes first so + // an interrupt leaves it retryable on the next refresh. yield* Effect.uninterruptible( Effect.gen(function* () { yield* Ref.update(rebuildable, (previous) => new Map(previous).set(instanceId, entry)); if (live === undefined) { return; } - // Drop the instance before closing it, or the map hands callers - // a bundle whose scope is gone for the whole build. Its last - // snapshot stands in meanwhile: aggregators treat an id that is - // in neither list as gone and prune it, so leaving the id - // nowhere would blank the provider's card until the build lands. + // Drop the instance before closing it so callers never get a bundle with a dead scope. Its last + // snapshot stands in meanwhile: an id in neither list is pruned, blanking the provider card. const parked = yield* live.instance.snapshot.getSnapshot; yield* Ref.set(entries, withoutKey(previousEntries, instanceId)); yield* Ref.update(unavailable, (previous) => new Map(previous).set(instanceId, parked)); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 35ee52d16cd0..6260dcdd99e6 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -52,9 +52,7 @@ export const deriveProviderInstanceConfigMap = ( merged[instanceId] = { driver: driver.driverKind }; } - // A project that turns an instance on needs it alive and probed even when the machine itself - // has it off, or a thread in that project would route to an instance nobody ever checked in - // on. + // Probe project-enabled instances even if disabled machine-wide; project threads can still route to them. for (const overrides of Object.values(settings.projectSettingsOverrides)) { for (const [rawInstanceId, projectOverride] of Object.entries( overrides?.providerInstances ?? {}, diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index f7675ff33a38..51322826230d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,9 +1,4 @@ -/** - * Closes the per-instance `Scope` of every removed or replaced entry (tearing - * down adapter processes, refresh fibres, temp files) BEFORE creating the - * replacement — `reconcile` guarantees "at most one live instance per id" at - * all times. - */ +/** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 266de2339137..3245ee61294e 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -16,11 +16,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | CursorKeychain.CursorKeychain | IdAllocator.IdAllocatorV2; -/** - * `providerContinuationRequestsLayer` must be the same layer reference the - * orchestration runtime provides to its continuation worker so Effect layer - * memoization yields one shared queue. - */ +/** Reuse the continuation worker's exact layer reference so memoization shares one queue. */ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, CodexAdapterV2.layerAppServerClientFactory, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 15df1e8c601c..0b6ffcc343b5 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -677,8 +677,6 @@ it.layer( message: "Checking ACP authentication, models, and commands in the background...", models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], } satisfies ServerProvider; - // A saved sign-in confirmation marks the readiness snapshot authenticated - // before discovery has run. const confirmedCheckingProvider = { ...checkingProvider, auth: { status: "authenticated" }, @@ -1269,12 +1267,8 @@ it.layer( ); }); - // Refresh is the only moment a rotated 1Password secret can reach a - // provider: the environment is resolved once, when the driver builds - // the instance, and the process it spawns keeps that copy. So refresh - // has to drop the cached secret AND rebuild the instances that read - // one - a re-probe of the existing process would report the old - // credential right back. + // Refresh must drop cached secrets AND rebuild instances that read one: the environment is + // resolved once at driver build, so a re-probe would report the old credential. it.effect("drops cached secrets and rebuilds the instances that read them", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -1582,7 +1576,6 @@ it.layer( slashCommands: [COMPACT_SLASH_COMMAND], skills: [], } as const satisfies ServerProvider; - // Codex's account check failed; its skill scan did not. const unhealthyProvider = { ...healthyProvider, status: "error", @@ -1655,8 +1648,6 @@ it.layer( yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.deepStrictEqual((yield* workspaceOf)?.slashCommands, [COMPACT_SLASH_COMMAND]); - // An expired scan runs while the account check fails: the skills are - // current, but commands that ride on the health check are unknown. yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); yield* Ref.set(scanResult, { ...unhealthyProvider, skills: laterSkills }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); @@ -1668,14 +1659,10 @@ it.layer( assert.strictEqual(provider?.status, "error"); assert.strictEqual(provider?.message, unhealthyProvider.message); - // A pending entry is rescanned on the next request; an empty scan - // removes the skills. yield* Ref.set(scanResult, { ...unhealthyProvider, skills: [] }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.deepStrictEqual((yield* workspaceOf)?.skills, []); - // A driver that discovers its own commands reports them even while - // health is in error. const discoveredCommand = { name: "review", description: "Review changes" }; yield* Ref.set(scanResult, { ...unhealthyProvider, @@ -1687,7 +1674,6 @@ it.layer( assert.deepStrictEqual(discovered?.slashCommands, [discoveredCommand]); assert.strictEqual(discovered?.slashCommandsPending, undefined); - // Once health recovers, the next scan restores the full entry. yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); yield* Ref.set(scanResult, { ...healthyProvider, skills: laterSkills }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); @@ -2588,8 +2574,6 @@ it.layer( describe("checkClaudeProviderStatus", () => { it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { - // The capability probe resolves for a logged-out CLI, so `tokenSource: - // "none"` is the only thing separating it from an authenticated one. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ @@ -2826,8 +2810,6 @@ it.layer( it.effect("keeps an API key install authenticated when it reports no token source", () => Effect.gen(function* () { - // `ANTHROPIC_API_KEY` never populates `tokenSource`, so reading that - // field alone would log the install out. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ @@ -2851,10 +2833,6 @@ it.layer( it.effect("keeps a CLI that says nothing about its account authenticated", () => Effect.gen(function* () { - // Profile-authenticated installs report no token source at all, and a - // CLI too old to send an account payload reports nothing whatsoever. - // Only `tokenSource: "none"` disproves authentication; saying nothing - // is not the same as saying no. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 928f86ca4336..6332b0e245da 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -61,11 +61,7 @@ export class ProviderRegistry extends Context.Service< provider?: ProviderDriverKind, ) => Effect.Effect>; - /** - * When the instance id is unknown the call resolves with the currently - * cached list (no error) — matching the legacy `refresh` shim behaviour so - * transport layers don't have to special-case unknowns. - */ + /** Unknown instance ids return the cached list without error. */ readonly refreshInstance: ( instanceId: ProviderInstanceId, ) => Effect.Effect>; @@ -160,9 +156,7 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver === ProviderDriverKind.make("pi")) { - // Pi reports known auth only after RPC inventory discovery completes. - // An empty inventory is warning/unauthenticated; failed or interactive - // discovery stays ready/unknown and must keep the last known models. + // Pi auth becomes known only after inventory discovery: empty means unauthenticated; failed/interactive discovery preserves unknown auth and cached models. return !( provider.installed && (provider.status === "ready" || provider.status === "warning") && @@ -171,13 +165,7 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => } if (provider.driver === ProviderDriverKind.make("acpRegistry")) { - // ACP Registry discovery probes return the agent's complete inventory, so - // a completed probe replaces the model list — otherwise agents that rename - // or collapse models leave stale entries pinned forever through the - // snapshot cache. Only a completed probe reports `nativeSessions`; - // readiness-only and failed probe snapshots only know the "default" - // placeholder and stay partial. Auth is no signal here: agents without - // sign-in stay "unknown" after every probe. + // Only completed ACP discovery provides nativeSessions and inventory; placeholder probes cannot establish auth for agents without sign-in. return !( provider.installed && provider.status === "ready" && @@ -245,11 +233,7 @@ const mergeProviderModels = ( : mergedModels; }; -/** - * Antigravity's health check only initializes the agent, so after a server - * restart it reports the account as unchecked. The saved Google login still - * works, and the previous snapshot proves it. - */ +/** Antigravity health checks only initialize; preserve previously verified Google auth across restarts. */ const carrySavedAntigravityAccount = ( previousProvider: ServerProvider, nextProvider: ServerProvider, @@ -332,9 +316,7 @@ const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { return provider.instanceId; }; -// Each call re-captures the instance's `snapshot` closures, so after -// `ProviderInstanceRegistry` rebuilds an instance (e.g. because its settings -// changed), a fresh source rides the new PubSub instead of a closed one. +// Rebuilt instances have new PubSubs; capture their fresh closures instead of a closed source. const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, @@ -550,8 +532,7 @@ export const layer = Layer.effect( }, ) { const providers = yield* upsertProviders([provider], options); - // Reclassify the current read model after fetching. Never republish the - // probe captured before the fetch: a newer health result may have landed. + // Reclassify the current model after fetch; republishing the old probe could overwrite newer health results. if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { yield* manifestService.refresh.pipe( Effect.andThen(upsertProviders([], { persist: false })), @@ -635,8 +616,7 @@ export const layer = Layer.effect( }, ); if (claimed.owner) { - // Detached so an interrupted first caller cannot cancel the pass that - // other callers are already awaiting. + // Detach shared passes so the first caller's interruption cannot cancel other callers' work. yield* reloadSecretBackedInstances().pipe( Effect.andThen(getLiveSources), Effect.flatMap((sources) => @@ -756,9 +736,7 @@ export const layer = Layer.effect( provider: ProviderDriverKind, options?: { readonly fresh?: boolean }, ) { - // Read the instance registry, not `liveSubsRef`: the latter trails - // reconciliation, and an update must never run a retired instance's - // command against a freshly configured executable. + // liveSubsRef trails reconciliation and could run a retired instance's command; read the registry. const instance = yield* instanceRegistry.getInstance(instanceId); if (!instance || instance.driverKind !== provider) { return makeManualProviderMaintenanceCapabilities(provider); @@ -817,10 +795,7 @@ export const layer = Layer.effect( } } - // Fork long-lived subscriptions to each new/rebuilt instance's - // change stream before reading its current snapshot. If the - // driver's own initial probe finishes during this sync, either - // the current read or the active subscriber observes the result. + // Subscribe before reading so a probe completing mid-sync is observed by at least one path. for (const [, instance] of newlyAdded) { const source = buildSnapshotSource(instance); yield* Stream.runForEach(source.streamChanges, (provider) => @@ -878,18 +853,9 @@ export const layer = Layer.effect( }), ); /** - * Re-read every credential that lives in an external secret store and - * rebuild the instances that consume one. - * - * A refresh is the user's way of saying "go look again", so it is also - * where a rotated secret should take effect. Dropping the cached value - * alone would change nothing: a driver resolves its environment once, at - * create time, and the provider process it spawns inherits that copy. The - * instance has to be rebuilt for the new value to reach the next process. - * - * Instances whose environment is all literals are left alone, so the - * common refresh stays a plain re-probe. Threads already running keep the - * process they were started with. + * Re-read every external-store credential and rebuild the instances that consume one: drivers + * resolve their environment once at create time, so dropping the cache alone changes nothing. + * Literal-only instances and running threads are untouched. */ const reloadSecretBackedInstances = Effect.fn("reloadSecretBackedInstances")(function* ( instanceIds?: ReadonlyArray, @@ -1007,8 +973,7 @@ export const layer = Layer.effect( ) { return providers; } - // Drivers spread their machine snapshot, whose `checkedAt` is the last - // health check. The TTL needs the time this scan started reading files. + // Use scan start for TTL, not checkedAt inherited from the machine health check. const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; @@ -1028,8 +993,7 @@ export const layer = Layer.effect( : Effect.void; return yield* refreshMachineSnapshot.pipe( Effect.andThen(instance.snapshotForCwd(input.cwd)), - // A failed scan fails this effect, so a returned snapshot is a real - // inventory even while the provider's health check reports an error. + // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap((scopedSnapshot) => instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { @@ -1045,9 +1009,7 @@ export const layer = Layer.effect( ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { ...scopedSnapshot, checkedAt: scannedAt, - // Unless the driver reports its own command discovery, - // commands come from the health check. A failed check - // leaves them unknown, so keep the last ones and retry. + // Without driver command discovery, retain previous commands after failed health checks and retry. slashCommandsPending: scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", }) diff --git a/apps/server/src/provider/ProviderSecretReference.ts b/apps/server/src/provider/ProviderSecretReference.ts index c7f18e3ec814..3b9fcce3a626 100644 --- a/apps/server/src/provider/ProviderSecretReference.ts +++ b/apps/server/src/provider/ProviderSecretReference.ts @@ -1,18 +1,6 @@ /** - * Provider environment values that name a secret instead of carrying one. - * - * A user who keeps a provider credential in a secret store configures the - * variable with a secret source instead of the secret itself: either a named - * secret provider from `settings.secretProviders` - * (`{ kind: "secret-provider", provider, reference }`) or the older inline - * 1Password source (`{ kind: "1password", reference, account }`). - * `ProviderSecretResolver` swaps the source for the real value on the way into - * the provider process, so the credential never lands in `settings.json` or - * the on-disk secret store, and rotating it in the store rotates it here. - * - * These helpers are pure so the instance registry can ask "does this - * environment read from a secret store?" without depending on the resolver. - * + * Provider environment values that name a secret (`secret-provider` or inline `1password`) + * instead of carrying one. Pure helpers, so the registry need not depend on the resolver. * @module provider/ProviderSecretReference */ import type { @@ -85,13 +73,8 @@ export function hasProviderSecretReference( } /** - * Every distinct secret across a set of environments, in the order they were - * first seen. - * - * Resolution is per secret but unlocking is per `op` invocation, so the caller - * that is about to build many instances wants the whole list up front: one - * call covering every secret costs one authorization, where one call per - * instance costs one each. + * Every distinct secret across a set of environments, in first-seen order. Unlocking is per `op` + * invocation, so callers building many instances want the whole list up front. */ export function collectProviderSecretReferences( environments: Iterable, diff --git a/apps/server/src/provider/ProviderSecretResolver.ts b/apps/server/src/provider/ProviderSecretResolver.ts index 0aeade05bc3f..dc06436fd414 100644 --- a/apps/server/src/provider/ProviderSecretResolver.ts +++ b/apps/server/src/provider/ProviderSecretResolver.ts @@ -1,16 +1,7 @@ /** - * ProviderSecretResolver: turns environment values that name a secret (such as - * a 1Password secret source) into the secrets they name, once, and holds them - * in memory. - * - * Every provider instance resolves its environment when the driver builds it, - * and a single instance can rebuild several times per session. Shelling out - * to `op` on each of those is slow (seconds) and, worse, can put a biometric - * prompt in front of a user who only started a thread. The resolver therefore - * caches by secret (reference plus account) for the lifetime of the process; `invalidate` is wired - * to the Settings refresh button, which is the user's way of saying "go read - * it again" after rotating a credential. - * + * ProviderSecretResolver: resolves secret-naming environment values once and caches them per + * secret (reference plus account) for the process lifetime, so rebuilds do not re-run `op` or + * re-prompt. `invalidate` is wired to the Settings refresh button. * @module provider/ProviderSecretResolver */ import type { @@ -27,14 +18,9 @@ import type { ResolvedProviderEnvironment } from "@t3tools/provider-core/server/ import type { ProviderSecretReference } from "./ProviderSecretReference.ts"; /** - * An instance environment after its secret references have been read. - * - * `unresolved` names the variables the instance configures but whose value - * could not be read. They are tracked separately because being absent from - * `variables` is not enough: the child environment starts from the server's - * own, so a name left alone keeps whatever the server inherited under it. - * That is the wrong credential precisely when the user asked for a specific - * one, and it hides as "authenticated". + * An instance environment after its secret references were read. `unresolved` is tracked apart + * from `variables` because the child environment starts from the server's own, so an unset name + * would keep an inherited credential. */ export interface ResolvedProviderInstanceEnvironment { readonly variables: ResolvedProviderEnvironment; @@ -43,49 +29,26 @@ export interface ResolvedProviderInstanceEnvironment { export interface ProviderSecretResolverShape { /** - * Replace every secret the environment names with its value. Literal values - * pass through untouched. - * - * Never fails. A reference that cannot be read (1Password locked, `op` not - * installed, item deleted) is reported as unresolved rather than - * substituted with an empty string, so the provider reports the honest - * "unauthenticated" instead of failing later with a credential that looks - * present and is not. The instance registry unsets those names, - * which is what keeps the provider off a same-named credential the server - * happens to have inherited. + * Replace every named secret with its value; literals pass through. Never fails: an unreadable + * reference is reported unresolved, never as an empty string, and the registry unsets its name + * so an inherited same-named credential is not used. */ readonly resolve: ( environment: ProviderInstanceEnvironment | undefined, ) => Effect.Effect; /** - * Resolve `references` ahead of the callers that will ask for them, in as - * few reads as the secret store allows. - * - * Unlocking is charged per invocation of the store's CLI, not per secret, so - * a caller about to build ten instances that each resolve their own - * environment would otherwise cost ten authorizations. Priming turns that - * into one. - * - * Purely an optimization, and deliberately best effort: it never fails, and - * a batch that does not come back leaves the cache exactly as cold as it - * found it, so `resolve` falls back to reading one reference at a time with - * the same per-variable failure isolation it has always had. + * Resolve `references` ahead of callers in as few store invocations as possible, since unlocking + * is charged per invocation. Best effort and never fails: a failed batch leaves the cache cold. */ readonly prime: (references: ReadonlyArray) => Effect.Effect; /** - * Drop cached secrets so the next `resolve` re-reads them from the store: - * every secret by default, or only those read through the named providers - * given, so editing one provider never re-prompts for the others. Callers - * that need the new value to reach a running provider must also rebuild the - * instance: a provider process keeps the environment it was spawned with. + * Drop cached secrets so the next `resolve` re-reads them: all by default, or only those from + * the named providers. Running providers keep their spawn-time environment until rebuilt. */ readonly invalidate: (providers?: ReadonlySet) => Effect.Effect; /** - * Read one secret source now, for the Settings "check" button, and report - * whether it resolved. Reads fresh, bypassing the cache, and stores a - * successful read in the cache so the next provider refresh does not prompt - * again. Never fails and never returns the value; a failure carries the - * store's own reason. + * Read one secret source fresh for the Settings "check" button. A success is cached; never + * fails and never returns the value. */ readonly check: (source: ProviderSecretSource) => Effect.Effect; /** diff --git a/apps/server/src/provider/ProviderSecretResolverLive.test.ts b/apps/server/src/provider/ProviderSecretResolverLive.test.ts index dadd8bc2638f..5a57d50b7d24 100644 --- a/apps/server/src/provider/ProviderSecretResolverLive.test.ts +++ b/apps/server/src/provider/ProviderSecretResolverLive.test.ts @@ -225,17 +225,8 @@ describe("ProviderSecretResolverLive", () => { const SECOND_REFERENCE = "op://Private/codex/credential"; /** - * Spawner that answers each `op` invocation from `handler`, which is handed the - * argv and, for `op inject`, the template `op` would have read. - * - * The template is read back off disk through the `-i` path in the argv, which - * is how `op` itself receives it. Reading it any other way would let a change - * that stops writing the file pass, and that is the shape of the bug this - * spawner exists to catch. - * - * The template matters: `prime` picks a random separator per call, so a test - * cannot hard-code the output. Recovering the separator from the template is - * also what proves the batch and the split agree on a format. + * Spawner that answers each `op` invocation from `handler`. The `op inject` template is read + * back from the `-i` path in argv, and its random separator proves batch and split agree. */ function scriptedOpSpawner( handler: ( diff --git a/apps/server/src/provider/ProviderSecretResolverLive.ts b/apps/server/src/provider/ProviderSecretResolverLive.ts index edce32a612f6..b8adedbb57e2 100644 --- a/apps/server/src/provider/ProviderSecretResolverLive.ts +++ b/apps/server/src/provider/ProviderSecretResolverLive.ts @@ -1,47 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -- the op inject separator only needs a random token at this subprocess boundary /** - * ProviderSecretResolverLive: implementation of `ProviderSecretResolver` that - * reads each secret store through its HTTP API or its own CLI. - * - * A named secret provider is looked up in `settings.secretProviders` when its - * secret is read, not when the reference is collected, so the reference stays - * a pure value and the provider's current configuration is what gets used. - * Removing a provider leaves its references unresolved rather than failing. - * - * OpenBao reads the KV v2 secret over its HTTP API: - * `GET
/v1//data/` with the token in `X-Vault-Token`. - * The provider's `auth` names the one place the token comes from, with no - * fallback to another: `token` is the one stored on the provider, `env` is the - * server process's `BAO_TOKEN`, and `cli` is the output of `bao print token`, - * so the CLI's own login applies, the same way `op` uses the user's existing - * session. - * - * AWS Secrets Manager reads `aws secretsmanager get-secret-value` in the - * provider's region, with its named profile when one is set. No credentials - * are stored: the CLI resolves them from that profile or its default chain - * (environment, default profile, SSO, instance role). A reference is - * `name-or-arn` for the whole secret string or `name-or-arn#key` for one key - * of a JSON secret. Binary secrets are not supported. - * - * 1Password resolution is `op read --account `, which is the same - * command the user would run by hand and inherits their existing `op` session, - * so there is no second place to configure credentials. The account is what - * keeps a user signed into several 1Password accounts from reading against - * whichever one `op` picks by default. Reads run one at a time: two - * concurrent reads against a locked vault stack up two biometric prompts. - * - * The prompt is charged per `op` invocation rather than per secret, so reading - * one reference at a time makes the whole fleet cost one authorization each. - * `prime` exists for that: `op inject` substitutes any number of references in - * a single process, so the caller that is about to build every instance pays - * one prompt per account for all of them. - * - * Failures are cached alongside successes. If the vault is locked when the - * first thread starts, every later thread in that session would otherwise - * re-prompt; caching the miss keeps the failure quiet and puts recovery on - * the refresh button, which is where the user already looks when a provider - * shows as logged out. - * + * Reads each secret store through its HTTP API or CLI. Providers are looked up in + * `settings.secretProviders` at read time. Failures are cached so a locked vault does not + * re-prompt; recovery is the refresh button. * @module provider/ProviderSecretResolverLive */ import { @@ -127,26 +88,9 @@ const OPENBAO_REQUEST_TIMEOUT = Duration.seconds(10); const SECRET_CACHE_CAPACITY = 64; /** - * Read many references from one account in one `op inject`. - * - * `op inject` substitutes references inside a template, so the template is the - * references themselves joined by a separator, and the output is the secrets - * in the same order. The separator is random per call because a secret can - * contain anything at all, newlines included: splitting on a fixed marker - * would let a secret that happened to contain it shift every later value. - * - * Returns `undefined` when the batch cannot be trusted as a whole, which - * includes a single bad reference, since `op` resolves the template or fails - * it. The caller treats that as "not primed" and reads one at a time, which is - * both the per-variable failure isolation and the way the user finds out which - * reference is the broken one. - * - * The template goes to `op` through a scoped temp file rather than stdin. - * `op` only reads piped input from a named pipe, and a child spawned from Node - * is handed a socket pair, so a piped template is never seen and the batch - * fails with "expected data on stdin but none found" every single time. The - * file holds only `op://` references, which already sit in settings in plain - * text; the secrets themselves come back on stdout and never touch disk. + * Read many references from one account in one `op inject`, via a scoped temp file because `op` + * ignores piped stdin from Node. The separator is random per call so a secret cannot shift later + * values. Returns `undefined` when the batch fails as a whole; the caller reads one at a time. */ const readSecretsTogether = Effect.fn("readSecretsTogether")(function* ( account: OnePasswordAccount, @@ -274,12 +218,9 @@ const nonEmpty = (value: string | undefined) => value === undefined || value.length === 0 ? undefined : value; /** - * The token the API read authenticates with, from the one source the - * provider's `auth` names. `cli` asks `bao print token`, which owns where its - * login lives, so nothing here reads a token file or a `VAULT_*` variable, and - * the token variables are withheld from that child so it reports the login - * rather than the server's environment. - * Returns the reason, after logging it, when that source has no token. + * The token from the source the provider's `auth` names, with no fallback. `cli` asks + * `bao print token` with token variables withheld so it reports its own login. Returns the + * logged reason when that source has no token. */ const resolveOpenBaoToken = Effect.fn("resolveOpenBaoToken")(function* ( provider: SecretProviderId, diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 338588662935..7ee010b6f76d 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -86,9 +86,7 @@ describe("terminatePosixOwnedProcessTree", () => { const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); const parent = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-stale-siblings-")); - // Temp dirs are not cgroupfs: rmdir fails when control files exist, so the - // test injects populated/remove while still exercising the real selection - // and path-safety logic against a real parent directory tree. + // Unlike cgroupfs, temp directories cannot rmdir with control files; inject populated/remove. const populatedByPath = new Map(); const writeSibling = (name: string, populated: "0" | "1" | null) => { const path = NodePath.join(parent, name); @@ -162,8 +160,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe( bareGrok, ); - // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone - // (bareGrok lives only in cwd / PATH=scratch). + // Undefined PATH searches /usr/bin:/bin; the bareGrok fixture lives only in cwd. expect( AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), ).toBeUndefined(); @@ -290,8 +287,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, ); expect(missingTargetResult.status, missingTargetResult.stderr).toBe(125); - // CI's /bin/sh is dash, which runs the EXIT trap after a failed exec. Bash does not, - // so run the same wrapper under bash too. + // dash runs EXIT traps after failed exec; bash does not, so exercise both. const missingTargetBash = NodeChildProcess.spawnSync("bash", missingTarget.args, { encoding: "utf8", }); @@ -422,8 +418,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("rotates more than 64 live parents without scanning retained tombstones", () => Effect.gen(function* () { - // The server fixture's process group is the runner's real pid, so a synthetic - // parent sharing it would be skipped as the server's own group. + // Use a different synthetic parent group; the fixture shares the runner's real group, which cleanup skips. const parentPidBase = process.pid < 500_000 ? 500_000 : 1_000; const parents = Array.from({ length: 130 }, (_, index) => identity(parentPidBase + index, 100, parentPidBase + index, parentPidBase + index), @@ -647,7 +642,6 @@ describe("terminatePosixOwnedProcessTree", () => { grace: 0, rootPid: 100, }); - // Zombies remain visible but must not fail residual teardown. expect(fixture.processes.get(100)?.state).toBe("Z"); expect(fixture.processes.get(110)?.state).toBe("Z"); }), diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 9ba55af7cf2c..0b3ef2603dcb 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -248,9 +248,7 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi attachment.type === "file" && "source" in attachment && attachment.source?._tag === "pasted-text"; - // ProviderService has already put the file path in the text block. Keep a - // folded clipboard paste lazy so the agent can search or sample it rather - // than paying to embed the entire resource in context immediately. + // The text already carries the path; keep folded pastes lazy so agents can search without embedding the whole file. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index b31b66149c7c..ebbc234e149b 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -4,10 +4,7 @@ import type * as Path from "effect/Path"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -/** - * The agent gates each write behind `session/request_permission`, so only - * path containment is checked here. - */ +/** The agent requests permission for every write; check only path containment here. */ const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024; function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean { @@ -15,10 +12,7 @@ function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -/** - * Symlinks resolve before the check, including the final component, so a link - * inside the workspace cannot read or write through to a file outside it. - */ +/** Resolve all symlinks, including the final component, before checking workspace containment. */ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFilePath")( function* (input: { readonly fileSystem: FileSystem.FileSystem; @@ -34,8 +28,7 @@ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFil const real = yield* input.fileSystem.realPath(resolved).pipe( Effect.catch(() => Effect.gen(function* () { - // Only a missing file (a new write) falls back to its parent; a - // dangling or unreadable link must not be followed on write. + // Fall back to the parent only for missing files, never dangling or unreadable links. const entryExists = yield* input.fileSystem.readLink(resolved).pipe( Effect.as(true), Effect.catch(() => input.fileSystem.exists(resolved)), diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 5c1ee06ee5f2..d3940dfc3d8c 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -69,10 +69,7 @@ const decodeSecurityWarning = Schema.decodeUnknownOption( }), ); -/** - * The agent marks "Allow Always" on shell and web tools with a prompt injection - * warning in `_meta`. Surface it as option text so both clients can show it. - */ +/** Expose shell/web Allow Always _meta warnings as option text for both clients. */ function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { const meta = option._meta; if (!Predicate.isObject(meta)) return undefined; @@ -346,10 +343,7 @@ export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean { return toolCall.kind === "execute" && toolCall.status === "inProgress"; } -/** - * ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs - * or models. - */ +/** ACP 1.1.1 reports subagent calls as ordinary tools without child IDs or models. */ export function classifyAntigravitySubagentToolCall( toolCall: AcpToolCallState, rawPayload?: unknown, diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index be6174c33ffd..7a25c8323174 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -42,12 +42,7 @@ export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySession Effect.catch(() => Effect.logWarning("Could not remove temporary Antigravity session files.")), ); -/** - * Call once when the driver starts, before it launches any process, so a previous server that was - * killed mid-session cannot leave unpacked runtimes behind. The system temp directory belongs to - * other programs and Windows does not lock data files, so sweeping it could gut a live - * extraction. - */ +/** Sweep only private runtimes before launch; Windows does not protect live extracted data files from deletion. */ export const removeAntigravityRuntimeTempDirs = Effect.fn("removeAntigravityRuntimeTempDirs")( function* (tempDirectory: string) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 4e4d32ffc107..5030b53005e8 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -51,9 +51,7 @@ const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSet const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); -// Python splits BROWSER on the platform path separator before it parses quotes. -// EPIPE must still exit 0 so Python does not fall back to an OS browser after -// cancellation. +// Python splits BROWSER on path separators before parsing quotes. Exit 0 on EPIPE to prevent browser fallback after cancellation. const browserHelperSource = `process.stderr.on("error",()=>process.exit(0)).write(` + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + @@ -172,10 +170,7 @@ export function isAntigravitySignInRequiredError(error: unknown): boolean { export interface AntigravityInstanceDirectories { readonly profile: string; - /** - * It sits beside the profile, not inside it: the agent unpacks members up to 120 characters deep, - * and the profile's longer name would push them past Windows' 260-character path limit. - */ + /** Keep beside the profile: 120-character archive members under its longer name would exceed Windows' 260-character limit. */ readonly runtimeTemp: string; } @@ -207,17 +202,14 @@ function antigravityEnvironment( // Windows treats environment keys as case-insensitive. Remove aliases too. if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value; } - // The agent prefers GOOGLE_API_KEY over the GCP pair for Agent Platform, so the pair goes through - // settings.json instead of the environment. + // Put the GCP pair in settings.json because GOOGLE_API_KEY takes precedence in the environment. const credential = auth.authMethod === "gemini-api-key" && auth.apiKey ? { GEMINI_API_KEY: auth.apiKey } : auth.authMethod === "agent-platform" && auth.apiKey ? { GOOGLE_API_KEY: auth.apiKey } : {}; - // It unpacks about 1 GB into the system temp directory per launch and a force - // kill leaves that behind. Point it at a T3-owned directory so the driver can - // reclaim the space. + // Each launch unpacks about 1 GB; use T3-owned temp so the driver can reclaim files after force-kill. const tempDirectory = runtimeTempDirectory ?? profile.tempDirectory; return { ...environment, @@ -238,12 +230,7 @@ function antigravityEnvironment( }; } -/** - * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points - * at the private profile. Link the two skill directories back to the user's - * real `~/.gemini` so global skills load, while MCP servers, hooks, and - * credentials stay isolated. - */ +/** Private GEMINI_HOME isolates credentials/hooks/MCP; link global skills from the real ~/.gemini. */ const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { readonly profileDirectory: string; readonly userHome: string; diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index d494c4924d0e..7c0eeabb0a75 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -146,7 +146,6 @@ describe("Antigravity subscription limits", () => { expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(56); yield* probe; expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); - // Both quota endpoints are read on every probe. expect( requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")), ).toHaveLength(4); @@ -157,8 +156,7 @@ describe("Antigravity subscription limits", () => { Effect.gen(function* () { const { probe } = yield* fixture({ summaryByHost: { - // The daily endpoint has answered with only the Claude/GPT group; the - // other endpoint returns the full default summary. + // The daily endpoint can omit Gemini while the other endpoint returns all groups. "daily-cloudcode-pa.googleapis.com": { groups: [ { @@ -181,8 +179,6 @@ describe("Antigravity subscription limits", () => { ]); const byId = new Map(limits.windows.map((window) => [window.id, window])); expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); - // When endpoints disagree about a bucket, the most-used reading wins, - // whichever endpoint reported it. expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(70); expect(byId.get("3p-weekly")?.usedPercent).toBe(100); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 55ef3579942f..75333ae8262e 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -223,8 +223,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ), ).pipe(Effect.flatMap(decodeTokenResponse)); accessToken = refreshed.access_token; - // Each machine's sign-in has its own refresh token, so the Google - // account id is what lets clients merge one account across environments. + // Use Google account IDs to merge accounts across environments; refresh tokens differ per machine. account = yield* request( HttpClientRequest.get("https://www.googleapis.com/oauth2/v2/userinfo").pipe( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }), @@ -246,9 +245,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } - // Endpoints can report different quota groups (the daily endpoint has - // returned the Claude/GPT group without Gemini), so merge every endpoint's - // windows. The most-used reading of a bucket wins, as in modelWindows. + // Merge endpoints because quota groups differ; keep each bucket's most-used reading. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( operation: string, parse: (response: unknown) => Effect.Effect, diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index b66dac4c15a6..32b2347589c8 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -48,10 +48,7 @@ export type BuiltInUsageReadersEnv = | ProviderUsageReaderEnv | ProviderUsageReaderEnv; -/** - * Aggregation keeps the first copy of a duplicate record, so the order is part - * of the result. - */ +/** Order matters: aggregation keeps the first duplicate record. */ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< AnyProviderDriver > = [ diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index fbc3efa2e5d4..35973378edad 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -81,9 +81,7 @@ class ClaudeResetCreditError extends Schema.TaggedError( const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); -/** - * An unanswered or unconfirmed claim retries with the same request id. - */ +/** Retry unanswered or unconfirmed claims with the same request ID. */ export const isSettledClaudeResetCreditFailure = (error: unknown) => isClaudeResetCreditError(error) && error.reason !== "requestFailed" && @@ -196,10 +194,7 @@ const CLAIM_OUTCOMES = { ineligible: "noCredit", } as const satisfies Record; -/** - * `requestId` is the idempotency key: a retry with the same id is the same - * claim. - */ +/** requestId is the claim's idempotency key. */ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { readonly configDir: string; readonly accountConfigPath: string; @@ -255,8 +250,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu if (body.result === "cooldown") { return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); } - // Claude could not say whether the claim landed, so, like the CLI, keep the - // request id and let the retry ask about the same claim. + // Unknown outcomes retain the request ID so retries address the same claim. if (body.result === "unavailable") { return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); } diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index 7fc0a63a605d..f20d8b054363 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -16,8 +16,6 @@ describe("claudeUsageResponseToLimits", () => { five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, seven_day_opus: { utilization: 3, resets_at: null }, - // Newer CLIs add this on top of the typed keys; the pinned SDK - // typings do not know it yet. ...({ model_scoped: [ { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index e41dac250ace..d0acf43bb496 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -26,13 +26,7 @@ const WINDOWS: Readonly< seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, }; -/** - * The streamed event names the overage-included bucket by type - * (`seven_day_overage_included`), while `get_usage` names it by the model's - * `display_name`. Which model that is changes over time, so the probe records - * the name it saw and the event mapper reuses it; the mid-turn update then - * lands on the row the probe drew instead of opening a second one. - */ +/** Reuse probed display names for streamed bucket types so mid-turn updates target the same row. */ const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; // The SDK does not yet type the spend budget returned by `get_usage`. @@ -157,11 +151,7 @@ function makeWindow( }; } -/** - * Utilization is a 0–1 fraction on the streamed event. An overage-included - * event before any probe has named the bucket is dropped: guessing a name - * would draw a row the next probe cannot reconcile. - */ +/** Stream utilization is 0-1. Drop unnamed overage buckets until probed to avoid unreconcilable rows. */ export function claudeRateLimitEventToUpdate( info: SDKRateLimitInfo, names: ClaudeScopedLimitNames, @@ -181,9 +171,7 @@ export function claudeRateLimitEventToUpdate( return undefined; } -/** - * Percentages on the `get_usage` response are already 0–100. - */ +/** get_usage percentages are already 0-100. */ export function claudeUsageResponseToLimits(input: { readonly response: Pick; readonly checkedAt: string; @@ -204,8 +192,7 @@ export function claudeUsageResponseToLimits(input: { if (!window || typeof window.utilization !== "number") continue; windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); } - // The CLI filters `model_scoped` to the overage-included allowlist, which - // today holds one model; the first entry is the one the event refers to. + // The CLI's overage allowlist currently has one model; events refer to its first entry. let overageIncluded: string | undefined; for (const entry of readModelScoped(response.rate_limits)) { if (typeof entry.utilization !== "number") continue; diff --git a/apps/server/src/provider/codexModelCatalog.ts b/apps/server/src/provider/codexModelCatalog.ts index 037dad8b0d9b..a459b1af81de 100644 --- a/apps/server/src/provider/codexModelCatalog.ts +++ b/apps/server/src/provider/codexModelCatalog.ts @@ -14,10 +14,7 @@ const CodexModelAdapter = Schema.Struct({ }); const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); -/** - * Codex lists only the models its own build knows, so a model released after - * the installed CLI never shows up. - */ +/** Codex catalogs omit models released after the installed CLI. */ function codexUpdateRequiredModels( catalog: ModelCatalog.ProviderCatalog | undefined, draft: ServerProviderDraft, diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index 0baae05dd816..053700026b52 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -56,16 +56,11 @@ function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; } -/** - * `primary` / `secondary` are positions, not durations. Codex usually sends - * `windowDurationMins`; when it does not, paid plans expose the 5-hour and - * weekly pair and Free/Go expose one monthly allowance. - */ +/** primary/secondary are positions; without windowDurationMins, paid plans use 5-hour/weekly windows and Free/Go use monthly. */ function codexRateLimitsToWindows( snapshot: CodexRateLimitSnapshot, ): ReadonlyArray { - // Show the main allowance only. Model-specific notifications (such as Spark) - // must not replace its primary/secondary rows. Older CLIs omit the limit id. + // Keep model-specific limits out of main allowance rows; older CLIs omit limit IDs. if (snapshot.limitId && snapshot.limitId !== "codex") return []; const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; const positions = [ @@ -158,18 +153,12 @@ export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerE } } -/** - * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a - * field the update omits keeps the value observed earlier in the session, so a - * later notification that only names the limit it reached must not drop the - * windows an earlier one carried. - */ +/** Rate-limit notifications are partial: omitted fields preserve earlier session values. */ export function mergeCodexRateLimits( previous: CodexRateLimitSnapshot | undefined, update: CodexRateLimitSnapshot, ): CodexRateLimitSnapshot | undefined { - // Model-specific snapshots (such as Spark) describe a different allowance - // and must not overwrite the main one, the same rule the usage rows apply. + // Model-specific snapshots must not overwrite the main allowance. if (update.limitId && update.limitId !== "codex") return previous; if (!previous) return update; return { diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 2330f814e319..4c35b2a53536 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -52,8 +52,7 @@ const provider: ServerProvider = { describe("provider compatibility", () => { it("keeps releases before V2 on the OpenCode 1 policy they shipped with", () => { - // Every installed build fetches main's manifest, and 0.0.45 and older run the - // V1 orchestrator, which cannot drive OpenCode 2. + // Older builds fetch main's manifest; their V1 orchestrator cannot drive OpenCode 2. const opencode = ProviderDriverKind.make("opencode"); for (const [version, expected] of [ ["1.14.19", "supported"], diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts index 038899a116d0..69322915f458 100644 --- a/apps/server/src/provider/providerCompatibility.ts +++ b/apps/server/src/provider/providerCompatibility.ts @@ -65,9 +65,7 @@ export function resolveProviderCompatibility( ); if (!policy) return undefined; const unprefixed = version?.replace(/^v/, ""); - // Cursor appends a build hash to its date; Google's ACP runtime uses a release prefix; - // Muse appends a release revision. Strip only these driver-specific forms, keeping - // semver prereleases unknown. + // Strip Cursor build hashes, Google ACP release prefixes and Muse revisions; leave semver prereleases unknown. const stable = driver === "cursor" ? unprefixed?.replace(/^(\d{4}\.\d{2}\.\d{2})-[a-f0-9]+$/, "$1") diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 43808ff37df8..75c592b7811a 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -39,10 +39,7 @@ const NATIVE_CLI_INSTANCE_ID = ProviderInstanceId.make("nativeCli"); const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const encoder = new TextEncoder(); -// Pin a non-win32 platform so `resolveSpawnCommand` is a no-op and the raw -// `{ command, args }` assertions below hold deterministically on any host -// (including Windows). Windows-specific resolution is covered by the dedicated -// win32 case at the end of this suite. +// Pin Linux so raw command/args assertions bypass Windows shim resolution. const layerNonWindowsPlatform = Layer.succeed(HostProcess.Platform, "linux"); function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 43d8b1e3916a..9dddf23579d8 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -43,8 +43,7 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; -// Every progress publish resends the provider list to each client, so the -// installer's output is sampled rather than forwarded line by line. +// Sample installer output because every progress update resends the provider list to all clients. const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); const UPDATE_PROGRESS_MAX_LENGTH = 200; // An installer may write for minutes without a line ending; only its tail matters. @@ -98,9 +97,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { - // On Windows the update tools are batch shims (e.g. `npm` -> `npm.cmd`), which a bare - // ChildProcess.spawn cannot launch (spawn npm ENOENT); resolveSpawnCommand finds the - // real `.cmd` and routes it through the shell. + // Windows batch shims require shell dispatch through resolveSpawnCommand. const resolved = yield* resolveSpawnCommand(input.command, input.args); const child = yield* input.spawner .spawn( @@ -292,7 +289,7 @@ function makeUpdateState(input: { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const manifestService = yield* ModelManifest.ModelManifest; @@ -425,9 +422,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ).pipe(Effect.asVoid); yield* setRunningMessage("Checking for the latest version"); - // The cached capabilities chose the lock; re-derive ownership - // now so the command that runs matches the executable as it is - // at click time, not as it was at the last health refresh. + // Recheck ownership after acquiring the cached lock in case the executable changed. const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, @@ -522,8 +517,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { verified, instanceId, ); - // A missing version alone is not held against it, since Cursor's `about` probe can - // fail transiently on a healthy binary. + // Cursor about probes can fail transiently; missing versions alone do not imply failure. const couldNotVerify = verifiedProviders.length === 0 || verifiedProviders.some((verifiedProvider) => { diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 486b7fd41046..fd62e75cab07 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -20,9 +20,7 @@ const mergeProviderModels = ( cachedModels: ReadonlyArray, ): ReadonlyArray => { const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); - // The fallback snapshot is built from current settings and already carries - // every custom model, so cached custom rows that are not in it were removed - // while the cache was stale and must not come back. + // Settings already supply current custom models; cached rows could resurrect removed ones. return [ ...fallbackModels, ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)), @@ -94,10 +92,7 @@ export const hydrateCachedProvider = (input: { : hydratedProvider; }; -/** - * Cache contents must still carry matching `instanceId` + `driver` identity - * before hydration. The filename alone is not trusted as a routing key. - */ +/** Require matching instanceId and driver before hydration; filenames alone cannot authorize routing. */ export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")( function* (input: { readonly cacheDir: string; diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index 45532eeb0550..189c43b54f7f 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -1,8 +1,4 @@ -/** - * Redeeming a reset credit is an account-level action: instances that share the - * directory holding a provider's login share the credit, so their redemptions - * must serialise on that directory, not the instance. - */ +/** Instances sharing a login directory share credits; serialize redemptions on that directory. */ import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -20,12 +16,7 @@ interface AccountRedemptionState { export class ResetCreditCoordinator extends Context.Service< ResetCreditCoordinator, { - /** - * The key is cleared when the provider reports an outcome, or when - * `isSettled` says a failure was a final answer (such as a cooldown). Any - * other failure (timeout included) keeps it so the next attempt is the same - * attempt. - */ + /** Reuse the idempotency key until an outcome or settled failure; timeouts keep it for retry. */ readonly redeem: ( accountKey: string, consume: (idempotencyKey: string) => Effect.Effect, @@ -35,13 +26,11 @@ export class ResetCreditCoordinator extends Context.Service< >()("t3/provider/resetCreditCoordinator") {} /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const statesRef = yield* Ref.make>(new Map()); - // Get-or-create through one Ref.modify so two first redemptions for the - // same account cannot each install their own lock. + // Create account locks atomically so concurrent first redemptions cannot use separate locks. const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { const existing = (yield* Ref.get(statesRef)).get(accountKey); if (existing) return existing; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 8e6284e3448d..cae8ea2c9380 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -23,9 +23,7 @@ rl.on("line", (line) => { } const { id, method } = message; if (openServerRequests.has(id)) { - // Real codex identifies the resolved request by its item id — the key the - // runtime correlates on — so the receipt exercises the same path that - // closes the approval card in the UI. + // Match real Codex's resolved item ID so the runtime closes the corresponding approval card. const request = openServerRequests.get(id); openServerRequests.delete(id); NodeFS.appendFileSync( diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs index e30acd7d6db3..5c4c84f2aa3e 100755 --- a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -89,9 +89,7 @@ lines.on("line", (line) => { }) + "\n", ); }); -// Stay alive for follow-up control requests, but never outlive the -// parent: the probe aborts the SDK without awaiting the child, so an -// unconditional interval would strand this process until reboot. +// Stay alive for control requests only while the parent lives; SDK abort does not await child exit. const keepAlive = setInterval(() => {}, 1_000); lines.on("close", () => { clearInterval(keepAlive); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 22a07e2a2317..63323b6122d2 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -24,16 +24,10 @@ export function fromProviders( }; } -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe(Effect.map((instance) => instance.pullRequests)), - ); - return fromProviders( - drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), - ); + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return fromProviders(drivers.flatMap(({ instance }) => instance.pullRequests ?? [])); }); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 8d9691fd79e3..649a1cea80d3 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -24,8 +24,7 @@ import * as ServerConfig from "../config.ts"; import { forkParked } from "../serverActivation.ts"; const CONCURRENT_READS = 512; -// Persistence prefixes every entry key with the store id, so each entry file -// name starts with it. The scope `revisions` file does not. +// Entry filenames have the store-id prefix; the scope revisions file does not. const STORE_ID = "pr-v2"; const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`); /** diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 76f703123c27..b4dd965570a9 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -94,6 +94,7 @@ const makeRepositories = Effect.gen(function* () { const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => ({ + provider: "github", number: 7, title: "Add the feature", body: "Adds the feature.", @@ -385,13 +386,7 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic name: "a head no source has", reportedHead: () => "e".repeat(40), shadow: "none", - expected: [ - "local objects:", - "the commit:", - "branch feature:", - "refs/pull/7/head:", - "refs/merge-requests/7/head:", - ], + expected: ["local objects:", "the commit:", "branch feature:", "refs/pull/7/head:"], }, ])("refuses to write a guide for $name", ({ reportedHead, shadow, expected }) => Effect.scoped( @@ -415,6 +410,8 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic assert.strictEqual(result.run?._tag, "Failed"); const detail = result.run?._tag === "Failed" ? result.run.detail : ""; for (const part of expected) assert.include(detail, part); + // Only the host's own head ref is tried. + assert.notInclude(detail, "refs/merge-requests/"); assert.deepStrictEqual(run.observed, []); assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-"))); const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index edd5dd2c7286..e0dc7309289b 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -24,7 +24,7 @@ import { } from "@t3tools/contracts"; import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as ServerConfig from "../config.ts"; import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; @@ -75,8 +75,7 @@ const make = Effect.gen(function* () { const serviceScope = yield* Effect.scope; const runs = yield* Ref.make>(new Map()); let runCounter = 0; - // Claiming a run and storing a finished guide take turns, so a run that still owns its pull - // request when it checks is the one whose guide is stored: a newer claim cannot land between. + // Serialize claims and guide writes so a newer claim cannot steal ownership between checking and storing. const ownership = yield* Semaphore.make(1); const keyFor = Effect.fn("PullRequestReviewGuideService.keyFor")(function* ( @@ -104,7 +103,7 @@ const make = Effect.gen(function* () { { projectId: ProjectId.make("own"), number: key.number, - repository: sourceControlRepositorySelector(identity) ?? "", + repository: sourceControlDefinitions.repositorySelector(identity) ?? "", }, identity, ); @@ -195,8 +194,7 @@ const make = Effect.gen(function* () { args: ["fetch", "--quiet", "--no-tags", "--", remote, ...args], timeoutMs: 5 * 60_000, }); - // The host names the base branch. A name git rejects, or one that would change the refspec - // (a leading `+`, a `:`), is refused before it reaches `git fetch`. + // Reject invalid branch names and refspec operators before fetching host-provided names. yield* git .execute({ operation: "PullRequestReviewGuideService.checkBaseBranch", @@ -213,8 +211,7 @@ const make = Effect.gen(function* () { }), ), ); - // Fetched into a ref of this run's own: FETCH_HEAD is shared by every fetch in the - // workspace, so a concurrent run could otherwise measure this change from its base. + // FETCH_HEAD is workspace-global; isolate this run's base from concurrent fetches. const baseRef = `refs/t3/review-guide/${runId}/base`; yield* fetch([`+refs/heads/${detail.baseBranch}:${baseRef}`]); yield* Effect.addFinalizer(() => @@ -239,7 +236,7 @@ const make = Effect.gen(function* () { detail: `The host reported a head for #${number} that is not a commit hash.`, }); } - // Not redundant: rev-parse prefers a branch or tag named like a short hash over the commit. + // rev-parse prefers same-named branches/tags over short hashes. const resolveHead = git.resolveCommit({ cwd: workspaceRoot, revision: sha }).pipe( Effect.filterOrFail( ({ commitSha }) => commitSha.startsWith(sha), @@ -251,6 +248,7 @@ const make = Effect.gen(function* () { ), ); const fetchAndResolve = (ref: string) => fetch([ref]).pipe(Effect.andThen(resolveHead)); + const headRef = sourceControlDefinitions.get(detail.provider).changeRequestHeadRef?.(number); const branchRef = `refs/heads/${branch}`; const checkBranch = git.execute({ operation: "PullRequestReviewGuideService.checkHeadBranch", @@ -266,11 +264,7 @@ const make = Effect.gen(function* () { source: `branch ${branch}`, head: checkBranch.pipe(Effect.andThen(fetchAndResolve(branchRef))), }, - { source: `refs/pull/${number}/head`, head: fetchAndResolve(`refs/pull/${number}/head`) }, - { - source: `refs/merge-requests/${number}/head`, - head: fetchAndResolve(`refs/merge-requests/${number}/head`), - }, + ...(headRef === undefined ? [] : [{ source: headRef, head: fetchAndResolve(headRef) }]), ]; const failures: Array = []; for (const { source, head } of sources) { @@ -336,8 +330,7 @@ const make = Effect.gen(function* () { .execute({ operation: "PullRequestReviewGuideService.readChange", cwd, - // Fixed prefixes: a user's diff.noPrefix or custom prefixes would hide the headers the - // parser reads. + // Override user diff prefixes so the parser can find headers. args: [ "-c", "core.quotePath=false", @@ -466,9 +459,7 @@ const make = Effect.gen(function* () { headSha: currentHeadSha, startedAt: yield* nowIso, }; - // Claimed atomically, so two readers pressing Generate start one run between them. The head - // is read again under the claim: a press that read an older head before a push must not - // replace a run another press started for the new one, so it asks to be pressed again. + // Claim atomically and re-read the head so stale clicks cannot replace a newer head's run. const claimed = yield* ownership.withPermits(1)( pullRequests.detail(input).pipe( Effect.flatMap((latest) => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 3566ad14b997..1d36a937b0b2 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,5 @@ -import { SourceControlProviderKind, VcsProcessExitError } from "@t3tools/contracts"; +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import { assert, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -10,9 +9,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/http"; import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, @@ -30,21 +27,10 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as GitLabPullRequestProvider from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as BitbucketPullRequestProvider from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; -import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota"; -import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import { fakePullRequestProvider } from "@t3tools/source-control-testing/fakeSourceControlDriver"; import { PullRequestProviderError, type ProviderChangeRequest, @@ -77,7 +63,7 @@ function project(input: { remoteName: "origin", remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, }, - provider: input.provider ?? "github", + provider: SourceControlProviderKind.make(input.provider ?? "github"), displayName: input.repository, }, } @@ -137,7 +123,7 @@ it.effect("does not wait for an in-flight detail read to display a preview", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Deferred.succeed(detailStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseDetail)), @@ -181,53 +167,30 @@ const FULL_REVIEW: PullRequestReviewCapabilities = { const FULL_REVIEWERS: PullRequestReviewerCapabilities = { request: true, listCandidates: true }; -function fakeProvider( - kind: SourceControlProviderKind, - overrides: Partial = {}, -): PullRequestProviderApi { - return { - kind, - capabilities: { - diff: true, - comment: true, - actions: ["merge", "ready", "draft", "close", "reopen"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - edit: { changeRequest: true, comment: true }, - }, - getViewer: () => Effect.succeed("bilal"), - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "ready", "draft", "close", "reopen"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }), - listChangeRequests: () => Effect.succeed({ items: [], truncated: false, continues: true }), - getChangeRequest: () => Effect.die("unused"), - getChangeRequestActivity: () => Effect.die("unused"), - getDiff: () => Effect.die("unused"), - runAction: () => Effect.void, - updateChangeRequest: () => Effect.void, - comment: () => Effect.void, - updateComment: () => Effect.void, - submitReview: () => Effect.void, - replyToThread: () => Effect.void, - setThreadResolution: () => Effect.void, - setReaction: () => Effect.void, - listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), - setReviewerRequest: () => Effect.void, - ...(kind === "github" ? { mergeMessageRewrite: (message: string) => message } : {}), - ...(kind === "azure-devops" - ? { repositoryKey: ({ canonicalKey }: { readonly canonicalKey: string }) => canonicalKey } - : {}), - ...overrides, - }; -} +// Like Azure DevOps, whose repository names repeat across organizations and projects. +const keyedByCanonicalKey = { + repositoryKey: ({ canonicalKey }: { readonly canonicalKey: string }) => canonicalKey, +}; + +/** A host whose SSH remotes name an alias, so discovery says which server serves them. */ +const sshAliasHost = { + resolveRemoteWebHost: ({ cwd, context, resolveContext }) => + isSshRemoteUrl(context.remoteUrl) + ? resolveContext({ + cwd, + context: { + ...context, + provider: { kind: SourceControlProviderKind.make("unknown"), name: "", baseUrl: "" }, + }, + }).pipe( + Effect.map((resolved) => + resolved === null || resolved.provider.kind === "unknown" + ? null + : new URL(resolved.provider.baseUrl).host, + ), + ) + : Effect.succeed(null), +} as SourceControlProvider["Service"]; function makeService(input: { readonly settings?: ServerSettings; @@ -236,8 +199,7 @@ function makeService(input: { readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"]; }) { - // Built into the test's own scope rather than provided call by call: the marks store owns a - // database, and `Effect.provide` would close it the moment the service was handed back. + // Build in the test scope; per-call Effect.provide would close the marks database on service acquisition. return Effect.flatMap( Layer.build( Layer.mergeAll( @@ -252,6 +214,10 @@ function makeService(input: { resolveLink: () => undefined, resolveHandle: input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), + get: (kind) => + Effect.succeed( + kind === "forgejo" ? sshAliasHost : ({} as SourceControlProvider["Service"]), + ), }), Layer.mock(ProjectService.ProjectService)({ listShells: (options) => @@ -290,7 +256,7 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ host, repository }) => { assert.strictEqual(host, "code.example.test"); assert.strictEqual(repository, "team/project"); @@ -346,7 +312,7 @@ it.effect("derives a legacy repository host after refining its provider", () => } as unknown as OrchestrationProjectShell; const service = yield* makeService({ projects: [legacy], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ context }) => Effect.succeed({ context: { @@ -377,7 +343,7 @@ it.effect("tries another checkout when provider refinement remains unknown", () }); const service = yield* makeService({ projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ cwd, context }) => { asked.push(cwd); return cwd === "/gone" @@ -419,7 +385,7 @@ it.effect("reads nothing from a host with no implementation, but reports it", () }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { listed.push(input.repository); return Effect.succeed({ @@ -458,7 +424,7 @@ it.effect("says where each repository carries on, and from nothing it has run ou project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -491,7 +457,8 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, listChangeRequests: () => Effect.succeed({ items: [changeRequest(7, "2026-07-02T00:00:00Z")], @@ -511,80 +478,63 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" }), ); -it.effect("continues Bitbucket listings through tied timestamps and terminates", () => - Effect.gen(function* () { - const boundary = "2026-10-12T00:00:00.000Z"; - const older = "2026-10-11T00:00:00.000Z"; - const rows = [1, 2, 3, 4, 5].map((id) => ({ - id, - title: `Pull request ${id}`, - state: "OPEN", - created_on: older, - updated_on: id <= 3 ? boundary : older, - source: { branch: { name: `feat/${id}` } }, - destination: { branch: { name: "main" } }, - links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${id}` } }, - })); - const provider = yield* BitbucketPullRequestProvider.make.pipe( - Effect.provide( - BitbucketPullRequestApi.layer.pipe( - Layer.provide( - Layer.mock(BitbucketApi.BitbucketApi)({ - request: ({ url }) => { - if (url === "/user") { - return Effect.succeed({ - body: JSON.stringify({ nickname: "bilal" }), - truncated: false, - }); - } - const filter = - new URL(url, "https://api.bitbucket.org").searchParams.get("q") ?? ""; - const updatedBefore = /updated_on <= (\S+)/.exec(filter)?.[1]; - const excluded = new Set( - Array.from(filter.matchAll(/id != (\d+)/g), (match) => Number(match[1])), - ); - const values = rows.filter( +it.effect( + "continues a timestamp-paged host's listings through tied timestamps and terminates", + () => + Effect.gen(function* () { + const boundary = "2026-10-12T00:00:00.000Z"; + const older = "2026-10-11T00:00:00.000Z"; + const rows = [1, 2, 3, 4, 5].map((number) => + changeRequest(number, number <= 3 ? boundary : older), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/w", + repository: "acme/web", + provider: "bitbucket", + host: "bitbucket.org", + }), + ], + providers: [ + // Pages the way Bitbucket does: inclusively by update time, excluding rows already sent. + fakePullRequestProvider(SourceControlProviderKind.make("bitbucket"), { + listChangeRequests: ({ cursor, limit }) => + Effect.sync(() => { + const seen = new Set(cursor?.seenAt ?? []); + const matching = rows.filter( (row) => - (updatedBefore === undefined || row.updated_on <= updatedBefore) && - !excluded.has(row.id), + (cursor === undefined || row.updatedAt <= cursor.updatedBefore) && + !seen.has(row.number), ); - return Effect.succeed({ body: JSON.stringify({ values }), truncated: false }); - }, - }), - ), - ), - ), - ); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/w", - repository: "acme/web", - provider: "bitbucket", - host: "bitbucket.org", - }), - ], - providers: [provider], - }); - let page = yield* service.list({ state: "open", limit: 2 }); - assert.deepStrictEqual( - page.entries.map((entry) => entry.number), - [1, 2], - ); - for (const expected of [[3, 4], [5]]) { - assert.isTrue(page.truncated); - assert.isNotEmpty(page.nextCursors); - page = yield* service.list({ state: "open", limit: 2, cursors: page.nextCursors }); + return { + items: matching.slice(0, limit), + truncated: matching.length > limit, + continues: true, + }; + }), + }), + ], + }); + let page = yield* service.list({ state: "open", limit: 2 }); assert.deepStrictEqual( page.entries.map((entry) => entry.number), - expected, + [1, 2], ); - } - assert.isFalse(page.truncated); - assert.deepStrictEqual(page.nextCursors, {}); - }), + for (const expected of [[3, 4], [5]]) { + assert.isTrue(page.truncated); + assert.isNotEmpty(page.nextCursors); + page = yield* service.list({ state: "open", limit: 2, cursors: page.nextCursors }); + assert.deepStrictEqual( + page.entries.map((entry) => entry.number), + expected, + ); + } + assert.isFalse(page.truncated); + assert.deepStrictEqual(page.nextCursors, {}); + }), ); it.effect("keeps a row already sent at the boundary instant from arriving twice", () => @@ -594,7 +544,7 @@ it.effect("keeps a row already sent at the boundary instant from arriving twice" project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -631,7 +581,7 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -667,7 +617,7 @@ it.effect("calls a transient viewer failure a failed operation, not a signed-out project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ @@ -701,7 +651,7 @@ it.effect("reports an unusable host over a merely failing one", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ @@ -712,7 +662,11 @@ it.effect("reports an unusable host over a merely failing one", () => }), ), }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + unavailableRequirement: { + missing: "Install glab.", + unauthenticated: "Run glab auth login.", + }, getViewer: () => Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), @@ -722,7 +676,10 @@ it.effect("reports an unusable host over a merely failing one", () => const error = yield* Effect.flip(service.list({ state: "open" })); assert.strictEqual(error._tag, "PullRequestUnavailableError"); - assert.strictEqual(error.message.includes("glab"), true); + if (error._tag === "PullRequestUnavailableError") { + assert.strictEqual(error.requirement, "Install glab."); + } + assert.strictEqual(error.message, "Install glab."); }), ); @@ -740,7 +697,7 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ host }) => Effect.succeed({ items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [], @@ -782,7 +739,7 @@ it.effect("reads a repository once when several worktrees share it", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { calls += 1; return Effect.succeed({ @@ -810,7 +767,7 @@ it.effect("keeps healthy repositories when one of them cannot be read", () => project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => input.repository === "pingdotgg/broken" ? Effect.fail(requestFailed) @@ -861,7 +818,7 @@ it.effect("offers each label once across repositories, despite one that cannot b }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listRepositoryLabels: (input) => { reads.push(input.repository); return input.repository === "pingdotgg/broken" @@ -879,7 +836,7 @@ it.effect("offers each label once across repositories, despite one that cannot b }, }), // A host with no repository labels is left out rather than failing the answer. - fakeProvider(SourceControlProviderKind.make("gitlab")), + fakePullRequestProvider(SourceControlProviderKind.make("gitlab")), ], }); @@ -902,7 +859,7 @@ it.effect("puts the author a query names exactly ahead of those it only begins", project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { searchRepositoryAuthors: (input) => { queries.push(input.query); return Effect.succeed({ @@ -938,7 +895,7 @@ it.effect("tries another workspace on the same host for the viewer", () => project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => input.cwd === "/healthy" ? Effect.succeed("bilal") @@ -974,7 +931,7 @@ it.effect("routing verifies the current account on the requested host without ca }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: (input) => { assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" }); return Effect.succeed({ @@ -1011,7 +968,7 @@ it.effect("refuses an action the host never claimed it could run", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1044,6 +1001,41 @@ it.effect("refuses an action the host never claimed it could run", () => }), ); +it.effect( + "asks to remove agent credits on merge only from a host that rewrites merge messages", + () => + Effect.gen(function* () { + const asked: Array = []; + const runAction: PullRequestProviderApi["runAction"] = (input) => + Effect.sync(() => { + asked.push(input.removeAgentCreditsOnMerge); + }); + for (const rewrites of [true, false]) { + const service = yield* makeService({ + settings: { ...DEFAULT_SERVER_SETTINGS, removeAgentCreditsOnMerge: true }, + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + runAction, + getChangeRequestSummary: () => + Effect.succeed(changeRequest(1, "2026-09-03T00:00:00Z")), + ...(rewrites ? { mergeMessageRewrite: (message: string) => message } : {}), + }), + ], + }); + yield* service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "merge", + }); + } + assert.deepStrictEqual(asked, [true, undefined]); + }), +); + it.effect("publishes a merge for immediate settlement only after host confirmation", () => Effect.scoped( Effect.gen(function* () { @@ -1056,7 +1048,7 @@ it.effect("publishes a merge for immediate settlement only after host confirmati project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => confirmationFails ? Effect.fail( @@ -1113,7 +1105,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes", project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.gen(function* () { yield* Deferred.succeed(confirmationStarted, undefined); @@ -1154,7 +1146,7 @@ it.effect("runs branch deletion only when the host and viewer support it, never let actions = 0; let supported = false; let allowed = true; - const base = fakeProvider(SourceControlProviderKind.make("github")); + const base = fakePullRequestProvider(SourceControlProviderKind.make("github")); const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ @@ -1219,109 +1211,41 @@ it.effect("runs branch deletion only when the host and viewer support it, never }), ); -it.effect("keeps branch deletion on the credential that passed source repository permissions", () => +it.effect("checks and deletes a branch inside one verified credential", () => Effect.gen(function* () { - let activeToken = "account-a"; - const requests: Array<{ operation: string; authorization: string | undefined }> = []; - const credentials = Layer.succeed( - GitHubCredentials.GitHubCredentials, - GitHubCredentials.GitHubCredentials.of({ - get: (host) => - Effect.sync(() => ({ - host, - token: Redacted.make(activeToken), - source: "gh" as const, - fingerprint: `${host}:${activeToken}`, - })), - invalidate: () => Effect.void, - }), - ); - const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - const operation = request.url.endsWith("/user") - ? "user" - : request.body._tag === "Uint8Array" - ? new TextDecoder().decode(request.body.body) - : ""; - requests.push({ operation, authorization: request.headers["authorization"] }); - let body; - if (operation === "user") { - body = { id: activeToken === "account-a" ? 101 : 202, login: activeToken }; - } else if (operation.includes("query PullRequestBranchToDelete")) { - body = { - data: { - repository: { - pullRequest: { - state: "MERGED", - headRefName: "feature", - baseRefName: "main", - headRefOid: "abc123", - headRepository: { id: "FORK_REPO" }, - }, - }, - }, - }; - } else if (operation.includes("query PullRequestSourceBranch")) { - body = { - data: { - node: { - __typename: "Repository", - defaultBranchRef: { name: "main" }, - ref: { target: { __typename: "Commit", oid: "abc123" } }, - }, - }, - }; - } else if (operation.includes("updateRefs(")) { - body = { data: { updateRefs: { clientMutationId: null } } }; - } else { - activeToken = "account-b"; - body = { - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: true, - rebaseMergeAllowed: true, - autoMergeAllowed: true, - viewerPermission: "READ", - pullRequest: { - viewerCanUpdate: false, - viewerDidAuthor: false, - headRepository: { viewerPermission: "WRITE" }, - }, - }, - }, - }; - } - return HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify(body), { - headers: { "content-type": "application/json" }, - }), - ); - }), - ), - ); - const provider = yield* GitHubPullRequestProvider.make.pipe( - Effect.provide( - GitHubPullRequestApi.layer.pipe( - // GraphQL reads go over `http` too, through the same credential and quota as REST. - Layer.provide( - GitHubRest.layer.pipe( - Layer.provideMerge(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubQuota.layer), - Layer.provideMerge(SourceControlRateLimit.layer), - ), - ), - Layer.provide(TestSourceControlHost.layer()), - Layer.provide(NodeServices.layer), - ), - ), - ); + let pinned = false; + const calls: Array = []; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [provider], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakePullRequestProvider("github").capabilities, + actions: ["delete-branch"], + deleteBranch: true, + }, + withVerifiedCredential: (_, use) => + Effect.suspend(() => { + pinned = true; + return use({ accountId: "101", viewer: "bilal", credentialFingerprint: "a" }); + }).pipe(Effect.ensuring(Effect.sync(() => (pinned = false)))), + getViewerPermissions: () => + Effect.sync(() => { + calls.push(`permissions pinned=${pinned}`); + return { + actions: ["delete-branch"], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: false, + }; + }), + runAction: (input) => + Effect.sync(() => { + calls.push(`${input.action} pinned=${pinned}`); + }), + }), + ], }); yield* service.runAction({ projectId: "p1" as ProjectId, @@ -1329,21 +1253,7 @@ it.effect("keeps branch deletion on the credential that passed source repository number: 1, action: "delete-branch", }); - assert.strictEqual(activeToken, "account-b"); - assert.strictEqual( - requests.filter(({ operation }) => operation.includes("updateRefs(")).length, - 1, - ); - assert.deepStrictEqual( - requests.map(({ authorization }) => authorization), - [ - "Bearer account-a", - "Bearer account-a", - "Bearer account-a", - "Bearer account-a", - "Bearer account-a", - ], - ); + assert.deepStrictEqual(calls, ["permissions pinned=true", "delete-branch pinned=true"]); }), ); @@ -1353,7 +1263,7 @@ it.effect("refuses an action this viewer may not take, and says what access it t const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: ["ready", "draft", "close", "reopen"], @@ -1387,7 +1297,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1448,7 +1358,7 @@ it.effect("keeps two hosts of one provider kind as two accounts", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), listChangeRequests: () => Effect.succeed({ @@ -1483,7 +1393,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.sync(() => { listCalls += 1; @@ -1538,7 +1448,7 @@ it.effect("refuses a repository that does not belong to the requested project", projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service @@ -1567,7 +1477,7 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("forgejo"), { + fakePullRequestProvider(SourceControlProviderKind.make("forgejo"), { getViewer: (input) => { viewers.push(input.host); assert.strictEqual(input.host, "code.example:3000"); @@ -1641,6 +1551,52 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after }), ); +it.effect("names an SSH remote's web host from a later checkout when the first cannot", () => + Effect.gen(function* () { + const hosts: Array = []; + const service = yield* makeService({ + projects: ["/first", "/second"].map((workspaceRoot, index) => + project({ + id: `p${index}`, + title: `checkout ${index}`, + workspaceRoot, + repository: "team/repo", + provider: SourceControlProviderKind.make("forgejo"), + host: "ssh.code.example", + remoteUrl: "git@ssh.code.example:team/repo.git", + }), + ), + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("forgejo"), { + listChangeRequests: (input) => { + hosts.push(input.host); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + resolveHandle: ({ cwd, context }) => + Effect.succeed({ + context: + cwd === "/first" + ? context! + : { + ...context!, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "http://code.example:3000", + }, + }, + provider: undefined as never, + }), + }); + + yield* service.list({ state: "open" }); + + assert.deepStrictEqual(hosts, ["code.example:3000"]); + }), +); + it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1655,7 +1611,7 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => remoteUrl: "http://code.example:4000/team/repo.git", }), ], - providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("forgejo"))], }); const failure = yield* service .summary( @@ -1683,7 +1639,7 @@ it.effect("resolves a project's repository identity when its shell has none cach const service = yield* makeService({ projects: [{ ...resolved, repositoryIdentity: null }], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], @@ -1707,7 +1663,7 @@ it.effect("routes a hosted reference to another repository through a project on project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); @@ -1742,7 +1698,8 @@ it.effect("routes Azure reads and writes through the requested organization's ch }), ), providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -1808,7 +1765,8 @@ it.effect.each([ target, ], providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -1847,7 +1805,8 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), runAction: () => Effect.die("must not modify the wrong organization"), }), @@ -1877,7 +1836,7 @@ it.effect("prefers a matching checkout over a non-repository project's directory project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(input.cwd); @@ -1904,7 +1863,7 @@ it.effect("requires an explicit host for a non-repository project's linked PR", Effect.gen(function* () { const service = yield* makeService({ projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service .summary( @@ -1927,7 +1886,7 @@ it.effect("carries the host's refusal out to the client, not just its sentence", project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { runAction: () => Effect.fail( new PullRequestProviderError({ @@ -1996,7 +1955,7 @@ it.effect("only lets a merge past the branch's rules when host and account both }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { runAction: (input) => { bypasses.push(input.bypassRules); return Effect.void; @@ -2057,7 +2016,7 @@ it.effect("refuses a review request this viewer may not make, and says what acce const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: ["ready", "draft", "close", "reopen"], @@ -2114,7 +2073,7 @@ it.effect.each(["list", "listStats"] as const)( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => lookup.pipe( Effect.as({ @@ -2162,7 +2121,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.sync(() => { viewerCalls += 1; @@ -2200,7 +2159,7 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories); return Effect.succeed({ @@ -2241,7 +2200,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ filters }) => Effect.gen(function* () { reads += 1; @@ -2295,7 +2254,7 @@ it.effect("does not revive old summaries when project epochs are evicted", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }), }), ], @@ -2322,7 +2281,7 @@ it.effect("close and reopen notify subscribed readers after invalidating their c project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), runAction: (input) => @@ -2381,7 +2340,7 @@ it.effect("reads a host's repositories in one search, and files the rows back un }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -2397,7 +2356,7 @@ it.effect("reads a host's repositories in one search, and files the rows back un }); }, }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ @@ -2433,7 +2392,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: () => Effect.succeed({ items: [ @@ -2442,6 +2401,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"), ], truncated: true, + unresolved: ["acme/docs"], }), }), ], @@ -2462,7 +2422,7 @@ it.effect("carries a slice on without sending the rows it already sent", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { cursors.push(input.cursor); return Effect.succeed({ @@ -2505,7 +2465,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", }), ), providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories.length); return Effect.succeed({ @@ -2525,7 +2485,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", assert.strictEqual(result.entries.length, 101); }), ); -it.effect("asks on its own for a repository a search answered nothing for", () => +it.effect("asks on its own for a repository a search could not vouch for", () => Effect.gen(function* () { const separately: string[] = []; const service = yield* makeService({ @@ -2534,7 +2494,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return repository === "acme/docs" @@ -2545,6 +2505,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = Effect.succeed({ items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")], truncated: false, + unresolved: ["acme/docs"], }), }), ], @@ -2566,6 +2527,38 @@ it.effect("asks on its own for a repository a search answered nothing for", () = ); }), ); +it.effect("believes a search's silence about a repository it vouches for", () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + listChangeRequestsAcross: () => + Effect.succeed({ + items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(separately, []); + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1], + ); + }), +); it.effect("fills in the line counts for the rows it is given", () => Effect.gen(function* () { const asked: Array = []; @@ -2581,7 +2574,7 @@ it.effect("fills in the line counts for the rows it is given", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: (input) => { asked.push(input.changeRequests); return Effect.succeed([ @@ -2589,7 +2582,7 @@ it.effect("fills in the line counts for the rows it is given", () => ]); }, }), - fakeProvider(SourceControlProviderKind.make("gitlab")), + fakePullRequestProvider(SourceControlProviderKind.make("gitlab")), ], }); @@ -2631,7 +2624,7 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })), getChangeRequestSummary: () => @@ -2692,9 +2685,9 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + ...fakePullRequestProvider(SourceControlProviderKind.make("github")).capabilities, viewedFiles: "host", }, getFilesViewed: () => @@ -2758,9 +2751,9 @@ it.effect("isolates routed caches for two credentials belonging to the same acco project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + ...fakePullRequestProvider(SourceControlProviderKind.make("github")).capabilities, viewedFiles: "host", }, getFilesViewed: () => @@ -2820,7 +2813,7 @@ it.effect("rejects mismatched routing credentials before use and preserves actio const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), withVerifiedCredential: (_, use) => use({ @@ -2876,7 +2869,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -2916,7 +2909,7 @@ it.effect("notifies linked threads when detail changes draft status in either di const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.sync(() => ({ ...hostedChangeRequest("body"), isDraft, updatedAt })), }), @@ -2957,7 +2950,7 @@ it.effect("deduplicates routed PR reports without dropping draft changes", () => const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("github"))], }); const changes = yield* service.subscribeStateChanges; for (const isDraft of [true, true, false, false, true]) { @@ -2983,7 +2976,7 @@ it.effect("preserves check observation time on cache hits and advances it on ref const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestChecks: () => Effect.sync(() => { reads += 1; @@ -3010,7 +3003,7 @@ it.effect("reuses an observed merged state for strict settlement reads", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("merged body", 4), @@ -3040,7 +3033,7 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.gen(function* () { detailCalls += 1; @@ -3103,7 +3096,7 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -3166,7 +3159,7 @@ it.effect.each([false, true])( }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities, getViewerPermissions: () => Effect.succeed({ @@ -3234,7 +3227,7 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -3293,7 +3286,7 @@ it.effect("keeps reading a host's thinned-out slices until a filtered page is fu }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: (input) => { const delivered = input.cursor?.delivered ?? 0; reads.push(delivered); @@ -3340,7 +3333,7 @@ it.effect("hands a host's own resume position back on the next read", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: (input) => { resumed.push(input.cursor?.resumeAfter); return Effect.succeed({ @@ -3374,7 +3367,7 @@ it.effect("judges the review filter only on a host that summarises its reviews", }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -3388,7 +3381,7 @@ it.effect("judges the review filter only on a host that summarises its reviews", continues: true, }), }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(3, "2026-07-02T00:00:00Z")], @@ -3417,9 +3410,9 @@ it.effect("partial host viewed writes invalidate marks but keep the diff cached" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + ...fakePullRequestProvider(SourceControlProviderKind.make("github")).capabilities, viewedFiles: "host", }, getDiff: () => @@ -3471,7 +3464,7 @@ const environmentViewedProvider = ( asked: Array>, unreadable: ReadonlySet = new Set(), ) => - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -3522,73 +3515,54 @@ const GITLAB_REFERENCE = { number: 1, }; -it.effect("tracks Forgejo viewed files through its diff and refuses truncated baselines", () => +it.effect("tracks environment viewed files by revision and refuses a baseline it cannot read", () => Effect.gen(function* () { - let alpha = "b485fe5"; - let truncated = false; - let diffReads = 0; - const provider = yield* ForgejoPullRequestProvider.make.pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - assert.strictEqual(input.host, "forge.example:3000"); - const viewer = input.path === "user"; - if (!viewer) { - assert.strictEqual(input.repository, "reviewer/project"); - assert.strictEqual(input.path, "repos/reviewer/project/pulls/1.diff"); - diffReads++; - } - return Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: viewer - ? JSON.stringify({ login: "reviewer" }) - : truncated - ? `diff --git a/alpha.ts b/alpha.ts\nindex eb2d7c5..${alpha} 100644\n` - : [ - "diff --git a/alpha.ts b/alpha.ts", - `index eb2d7c5..${alpha} 100644`, - "diff --git a/beta.ts b/beta.ts", - "index b39e8b3..5851425 100644", - 'diff --git "a/caf\\303\\251 notes.txt" "b/caf\\303\\251 notes.txt"', - "index ffd99ce..6dd9855 100644", - "diff --git a/deleted.txt b/deleted.txt", - "deleted file mode 100644", - "index 233f5c6..0000000", - "diff --git a/old.txt b/renamed.txt", - "similarity index 100%", - "rename from old.txt", - "rename to renamed.txt", - "", - ].join("\n"), - stderr: "", - stdoutTruncated: !viewer && truncated, - stderrTruncated: false, - }); - }, - }), - ), - ); + const revisions = new Map([ + ["alpha.ts", "b485fe5"], + ["beta.ts", "5851425"], + ["café notes.txt", "6dd9855"], + ["deleted.txt", "0000000"], + ["renamed.txt", ""], + ]); + let unreadable = false; + let revisionReads = 0; const service = yield* makeService({ projects: [ project({ - id: "forgejo", - title: "on forgejo", - workspaceRoot: "/forgejo", - repository: "reviewer/project", - provider: SourceControlProviderKind.make("forgejo"), - host: "forge.example:3000", - remoteUrl: "http://forge.example:3000/reviewer/project.git", + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: SourceControlProviderKind.make("gitlab"), }), ], - providers: [provider], + providers: [ + { + ...environmentViewedProvider(revisions, []), + getFileRevisions: (input) => + Effect.suspend(() => { + revisionReads++; + return unreadable + ? Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("gitlab"), + operation: "getFileRevisions", + reason: "failed", + detail: "The diff exceeded the output limit.", + }), + ) + : Effect.succeed({ + revisions: new Map( + input.paths.map((path) => [path, revisions.get(path) ?? ""] as const), + ), + complete: true, + }); + }), + }, + ], }); - const reference = { - projectId: "forgejo" as ProjectId, - repository: "reviewer/project", - host: "forge.example:3000", - number: 1, - }; - const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"]; + const reference = GITLAB_REFERENCE; + const paths = [...revisions.keys()]; yield* service.setFilesViewed({ ...reference, files: paths.map((path) => ({ path, viewed: true })), @@ -3597,9 +3571,9 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])), new Map(paths.map((path) => [path, "viewed"])), ); - assert.strictEqual(diffReads, 1); + assert.strictEqual(revisionReads, 1); - alpha = "aabbccd"; + revisions.set("alpha.ts", "aabbccd"); yield* service.invalidate({ reference }); assert.deepStrictEqual( new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])), @@ -3613,13 +3587,13 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba (yield* service.filesViewed(reference)).files.some((file) => file.path === "beta.ts"), ); - truncated = true; + unreadable = true; yield* service.invalidate({ reference }); yield* service.setFilesViewed({ ...reference, files: [{ path: "beta.ts", viewed: true }], }); - truncated = false; + unreadable = false; yield* service.invalidate({ reference }); assert.deepStrictEqual( (yield* service.filesViewed(reference)).files.find((file) => file.path === "beta.ts"), @@ -3859,7 +3833,7 @@ it.effect("finishes two presses on one file in the order they were made", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -3908,7 +3882,8 @@ const azureViewedService = (projects: ReadonlyArray) makeService({ projects, providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, capabilities: { diff: true, comment: true, @@ -4053,7 +4028,8 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ), providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, listChangeRequests: (input) => Effect.sync(() => { seen.push(input.cwd); @@ -4075,38 +4051,9 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ); -it.effect("pauses uncached GitLab activity after only the reactions endpoint is rate limited", () => +it.effect("pauses uncached activity on a host after one activity read is rate limited", () => Effect.gen(function* () { - let commandCalls = 0; - const provider = yield* GitLabPullRequestProvider.make.pipe( - Effect.provide( - GitLabPullRequestCli.layer.pipe( - Layer.provide(GitLabCli.layer), - Layer.provide( - TestSourceControlHost.layer({ - process: { - run: (input) => - Effect.suspend(() => { - commandCalls += 1; - return input.args[1] === "graphql" - ? Effect.fail( - new VcsProcessExitError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - exitCode: 1, - detail: "API rate limit exceeded.", - failureKind: "rate-limited", - }), - ) - : Effect.succeed(TestSourceControlHost.processOutput("[]")); - }), - }, - }), - ), - ), - ), - ); + let activityReads = 0; const service = yield* makeService({ projects: [ project({ @@ -4118,7 +4065,22 @@ it.effect("pauses uncached GitLab activity after only the reactions endpoint is host: "gitlab.example.test", }), ], - providers: [provider], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + getChangeRequestActivity: () => + Effect.suspend(() => { + activityReads++; + return Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("gitlab"), + operation: "getChangeRequestActivity", + reason: "rate-limited", + detail: "API rate limit exceeded.", + }), + ); + }), + }), + ], }); const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; @@ -4126,17 +4088,12 @@ it.effect("pauses uncached GitLab activity after only the reactions endpoint is _tag: "Failure", failure: { _tag: "PullRequestOperationError", - cause: { - _tag: "PullRequestProviderError", - reason: "rate-limited", - cause: { _tag: "GitLabCliRateLimitError" }, - }, + cause: { _tag: "PullRequestProviderError", reason: "rate-limited" }, }, }); - const firstCommandCalls = commandCalls; - assert.isAbove(firstCommandCalls, 0); + assert.strictEqual(activityReads, 1); - // A different merge request cannot reuse the first request's cached failure. + // A different change request cannot reuse the first request's cached failure. expect(yield* Effect.result(service.activity({ ...reference, number: 2 }))).toMatchObject({ _tag: "Failure", failure: { @@ -4151,7 +4108,7 @@ it.effect("pauses uncached GitLab activity after only the reactions endpoint is }, }, }); - assert.strictEqual(commandCalls, firstCommandCalls); + assert.strictEqual(activityReads, 1); }), ); @@ -4163,7 +4120,7 @@ it.effect("expands only files the change request changes, across every slice of project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getDiff: (input) => Effect.succeed( input.cursor === undefined diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index d7e1247b6241..782f32f6df28 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,9 +1,4 @@ -import { - canonicalRepositoryKey, - isSshRemoteUrl, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { parseDiffFilePaths } from "@t3tools/source-control-core/server/diffFileRevisions"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -23,8 +18,6 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, - pullRequestHostOf, - pullRequestProviderRequirement, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, type ProjectId, @@ -82,13 +75,14 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { type ProviderChangeRequest, @@ -119,22 +113,13 @@ function supersedesReading(current: PullRequestReading | undefined, next: PullRe return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); } -/** - * 99 and not 100, because every provider asks its host for one row over this to probe for a next - * page: 99 requests 100, which is exactly what a page of GitHub's API serves — GraphQL refuses - * `first` over 100 with EXCESSIVE_PAGINATION and REST clamps `per_page` to it — and what GitLab - * caps `per_page` at. - */ +// Providers request one extra row; 99 stays within GitHub/GitLab's 100-row API limit. const DEFAULT_REPOSITORY_LIST_LIMIT = 99; const REPOSITORY_CONCURRENCY = 12; const REPOSITORY_SEARCH_CHUNK = 100; const FILTERED_LIST_READS = 5; -/** - * Every read leaves the process — a CLI per repository, against hosts whose limits are low - * (GitHub's search API allows ~30 requests a minute) — so answers are shared for a short while and - * concurrent identical reads share one request. - */ +// Host reads spawn CLIs and hit low quotas; briefly share results and identical in-flight reads. const LIST_CACHE_TTL = Duration.seconds(30); const DETAIL_CACHE_TTL = Duration.seconds(60); const MERGED_DETAIL_CACHE_TTL = Duration.minutes(10); @@ -149,7 +134,6 @@ const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); const DIFF_STALE_WINDOW = Duration.minutes(10); const DIFF_SWEEP_INTERVAL = Duration.minutes(1); const VIEWER_CACHE_TTL = Duration.minutes(10); -const SEARCH_VISIBILITY_TTL = Duration.minutes(10); const STALE_DETAIL_WINDOW = Duration.minutes(10); const isPullRequestProviderError = Schema.is(PullRequestProviderError); const LIST_CACHE_CAPACITY = 64; @@ -325,9 +309,7 @@ export interface SupportedProject { readonly api: PullRequestProviderApi; readonly repository: string; readonly host: string; - /** - * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation. - */ + /** Unlike Azure repository names, remotes are unique across an organization. */ readonly remote: string; readonly repositoryKey: string | null; } @@ -372,10 +354,7 @@ function listCursorKey(host: string, repository: string): string { return `${host} ${repository.toLowerCase()}`; } -/** - * A slice that ends on the same instant it began on has to keep the earlier rows excluded as - * well as its own, or the read after it would hand them over again. - */ +// Preserve exclusions across slices sharing a timestamp to avoid redelivery. function nextListCursor( previous: ListCursor | undefined, fetched: ReadonlyArray, @@ -385,10 +364,7 @@ function nextListCursor( resumeAfter?: string, ): string | null { if (fetched.length === 0) return null; - // Taken from what the host answered rather than from what survived de-duplication: a slice can - // be entirely rows already sent — a hundred change requests touched in the same second is one - // repository's boring afternoon — and reading "nothing new" as "nothing left" would end the - // walk on the instant it was stuck on, with everything older unreachable for good. + // Use all fetched rows: a slice containing only duplicates must still advance to older rows. const oldest = fetched.reduce((left, right) => (right.updatedAt < left.updatedAt ? right : left)); return listCursorAt(previous, oldest.updatedAt, fetched, cursorAdvance, resumeAfter); } @@ -412,37 +388,48 @@ function isProviderUnusable(error: PullRequestProviderError): boolean { return error.reason === "missing-tool" || error.reason === "unauthenticated"; } -function providerDetail(error: PullRequestProviderError): string { - if (!isProviderUnusable(error)) return error.detail; - return ( - pullRequestProviderRequirement( - error.provider, - error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", - ) ?? error.detail - ); +type UnavailableRequirementOf = ( + kind: SourceControlProviderKind, +) => PullRequestProviderApi["unavailableRequirement"]; + +function unavailableRequirement( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string | undefined { + const requirement = requirementOf(error.provider); + if (requirement === undefined) return undefined; + return error.reason === "missing-tool" ? requirement.missing : requirement.unauthenticated; } -function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { - return new PullRequestUnavailableError({ - reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", - provider: error.provider, - cause: error, - }); +function providerDetail( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string { + if (!isProviderUnusable(error)) return error.detail; + return unavailableRequirement(requirementOf, error) ?? error.detail; } -function toPullRequestError( - operation: string, -): (error: PullRequestProviderError) => PullRequestError { - return (error) => - isProviderUnusable(error) - ? toUnavailableError(error) - : new PullRequestOperationError({ - operation, - detail: error.detail, - ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), - ...(error.refusal === undefined ? {} : { refusal: error.refusal }), - cause: error, - }); +function toPullRequestErrorWith( + requirementOf: UnavailableRequirementOf, +): (operation: string) => (error: PullRequestProviderError) => PullRequestError { + return (operation) => (error) => { + if (isProviderUnusable(error)) { + const requirement = unavailableRequirement(requirementOf, error); + return new PullRequestUnavailableError({ + reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + provider: error.provider, + ...(requirement === undefined ? {} : { requirement }), + cause: error, + }); + } + return new PullRequestOperationError({ + operation, + detail: error.detail, + ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), + ...(error.refusal === undefined ? {} : { refusal: error.refusal }), + cause: error, + }); + }; } function withRateLimitBackoff( @@ -504,7 +491,13 @@ function withRateLimitBackoff( ...(api.mergeMessageRewrite === undefined ? {} : { mergeMessageRewrite: api.mergeMessageRewrite }), + ...(api.unavailableRequirement === undefined + ? {} + : { unavailableRequirement: api.unavailableRequirement }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), + ...(api.readsPublicHostWithoutCheckout === undefined + ? {} + : { readsPublicHostWithoutCheckout: api.readsPublicHostWithoutCheckout }), getViewer: options?.viewerAllowsPause === true ? interactive("getViewer", api.getViewer) @@ -653,6 +646,21 @@ function rankFilterOptions( }; } +const remoteHostKey = (recognized: SourceControlProviderInfo, remoteUrl: string) => + `${recognized.baseUrl}\n${isSshRemoteUrl(remoteUrl) ? "ssh" : "web"}`; + +/** An HTTP remote names its server's web host, port included. */ +function httpRemoteHost(remoteUrl: string): string | null { + try { + const remote = new URL(remoteUrl); + return remote.protocol === "http:" || remote.protocol === "https:" + ? remote.host.toLowerCase() + : null; + } catch { + return null; + } +} + // Capture before the provider read so a slow response keeps its original freshness through caches. const observeRead = Effect.fnUntraced(function* (read: Effect.Effect) { const observedAt = yield* Clock.currentTimeMillis; @@ -664,6 +672,9 @@ export const make = Effect.gen(function* () { const stateChanges = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; + const requirementOf: UnavailableRequirementOf = (kind) => + registry.get(kind)?.unavailableRequirement; + const toPullRequestError = toPullRequestErrorWith(requirementOf); const serverSettings = yield* ServerSettings.ServerSettingsService; const projects = yield* ProjectService.ProjectService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; @@ -672,83 +683,131 @@ export const make = Effect.gen(function* () { const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository; const readCache = yield* PullRequestReadCache.PullRequestReadCache; - const refineUnknownProjectKinds = ( + /** + * Where each project's remote is served from, by the remote's recognised base URL: a remote no + * definition placed is identified by the hosting CLIs, and a host whose remotes may not name + * its web server says which one does. One project per base URL and transport answers for all + * of them. + */ + const resolveRemoteHosts = Effect.fn("PullRequestService.resolveRemoteHosts")(function* ( projects: ReadonlyArray, filter: Pick, - ) => { - type RefinementCandidate = { + ) { + type Candidate = { readonly project: OrchestrationProjectShell; - readonly provider: SourceControlProviderInfo; - readonly remoteName: string; - readonly remoteUrl: string; + readonly context: SourceControlProviderContext; }; - const refinements = new Map(); + const candidatesByKey = new Map(); for (const project of projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; if ( - (identity?.provider !== "unknown" && - !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) || - sourceControlRepositorySelector(project.repositoryIdentity) === null + !identity || + identity.provider === undefined || + sourceControlDefinitions.repositorySelector(identity) === null ) continue; - const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown")); + if (identity.provider !== "unknown") { + const provider = yield* sourceControlProviders + .get(identity.provider) + .pipe(Effect.orElseSucceed(() => null)); + if (provider?.resolveRemoteWebHost === undefined) continue; + } + const host = sourceControlDefinitions.changeRequestHost(identity, "unknown"); if ( filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase() && - pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !== - filter.host.toLowerCase() && + httpRemoteHost(identity.locator.remoteUrl) !== filter.host.toLowerCase() && !isSshRemoteUrl(identity.locator.remoteUrl) ) { continue; } const { remoteName, remoteUrl } = identity.locator; - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider !== null) { - const candidates = refinements.get(provider.baseUrl); - const candidate = { project, provider, remoteName, remoteUrl }; - if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]); - else candidates.push(candidate); - } + const recognized = sourceControlDefinitions.recognizeRemote(remoteUrl); + if (recognized === null) continue; + const candidate = { + project, + context: { + provider: + identity.provider === "unknown" + ? recognized + : { ...recognized, kind: identity.provider }, + remoteName, + remoteUrl, + ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) + ? { requestedHost: filter.host } + : {}), + }, + }; + const key = remoteHostKey(recognized, remoteUrl); + const candidates = candidatesByKey.get(key); + if (candidates === undefined) candidatesByKey.set(key, [candidate]); + else candidates.push(candidate); } - return Effect.forEach( - refinements, - ([baseUrl, candidates]) => - Effect.firstSuccessOf( - candidates.map(({ project, provider, remoteName, remoteUrl }) => - Effect.suspend(() => - sourceControlProviders.resolveHandle({ - cwd: project.workspaceRoot, - context: { - provider: - provider.kind === "forgejo" - ? { ...provider, kind: SourceControlProviderKind.make("unknown") } - : provider, - remoteName, - remoteUrl, - ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) - ? { requestedHost: filter.host } - : {}), - }, - }), - ).pipe( - Effect.flatMap((handle) => { - const refined = handle.context?.provider; - return refined === undefined || refined.kind === "unknown" - ? Effect.fail(undefined) - : Effect.succeed(refined); - }), + const resolveContext = (input: { + readonly cwd: string; + readonly context: SourceControlProviderContext; + }) => sourceControlProviders.resolveHandle(input).pipe(Effect.map((handle) => handle.context)); + type RemoteHost = { readonly kind: SourceControlProviderKind; readonly host: string | null }; + const resolveCandidate = ({ project, context }: Candidate) => + (context.provider.kind === "unknown" + ? resolveContext({ cwd: project.workspaceRoot, context }).pipe( + Effect.map((resolved) => + resolved === null || resolved.provider.kind === "unknown" + ? null + : { context: resolved, discovered: true }, ), - ), - ).pipe( - Effect.map((provider) => [baseUrl, provider] as const), - Effect.orElseSucceed(() => [baseUrl, null] as const), + ) + : Effect.succeed({ context, discovered: false }) + ).pipe( + Effect.flatMap((found) => + found === null + ? Effect.succeed(null) + : sourceControlProviders.get(found.context.provider.kind).pipe( + Effect.flatMap( + (provider) => + provider.resolveRemoteWebHost?.({ + cwd: project.workspaceRoot, + context: found.context, + // A remote discovery just placed is not looked up a second time. + resolveContext: found.discovered + ? () => Effect.succeed(found.context) + : resolveContext, + }) ?? Effect.succeed(null), + ), + Effect.orElseSucceed(() => null), + Effect.map((host): RemoteHost => ({ kind: found.context.provider.kind, host })), + ), ), + Effect.orElseSucceed(() => null), + ); + + // An SSH remote whose web host one checkout could not name may be named by the next; a + // kind found without a host still stands if none can. + const resolveKey = Effect.fn("PullRequestService.resolveRemoteHost")(function* ( + candidates: ReadonlyArray, + ) { + let fallback: RemoteHost | null = null; + for (const candidate of candidates) { + const resolved = yield* resolveCandidate(candidate); + if (resolved === null) continue; + if (resolved.host !== null || !isSshRemoteUrl(candidate.context.remoteUrl)) { + return resolved; + } + fallback ??= resolved; + } + return fallback; + }); + + return yield* Effect.forEach( + candidatesByKey, + ([key, candidates]) => + resolveKey(candidates).pipe(Effect.map((resolved) => [key, resolved] as const)), { concurrency: REPOSITORY_CONCURRENCY }, ).pipe(Effect.map((resolved) => new Map(resolved))); - }; + }); const listWorkspaceProjects = ( filter: Pick, @@ -780,11 +839,11 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((projects) => - refineUnknownProjectKinds(projects, filter).pipe( - Effect.map((refinedProviders) => ({ refinedProviders, projects })), + resolveRemoteHosts(projects, filter).pipe( + Effect.map((remoteHosts) => ({ remoteHosts, projects })), ), ), - Effect.map(({ refinedProviders, projects }) => { + Effect.map(({ remoteHosts, projects }) => { const supported: SupportedProject[] = []; const unimplemented = new Map< string, @@ -796,22 +855,18 @@ export const make = Effect.gen(function* () { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; - let kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = sourceControlRepositorySelector(project.repositoryIdentity); + let kind = identity?.provider; + const repository = sourceControlDefinitions.repositorySelector( + project.repositoryIdentity, + ); if (!identity || kind === undefined || repository === null) continue; - let refinedProvider: SourceControlProviderInfo | null | undefined; - if ( - kind === "unknown" || - (kind === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl)) - ) { - const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); - refinedProvider = provider === null ? null : refinedProviders.get(provider.baseUrl); - kind = refinedProvider?.kind ?? kind; - } - const host = - refinedProvider?.kind === "forgejo" - ? new URL(refinedProvider.baseUrl).host.toLowerCase() - : pullRequestHostOf(identity, kind); + const recognized = sourceControlDefinitions.recognizeRemote(identity.locator.remoteUrl); + const remote = + recognized === null + ? undefined + : remoteHosts.get(remoteHostKey(recognized, identity.locator.remoteUrl)); + kind = remote?.kind ?? kind; + const host = remote?.host ?? sourceControlDefinitions.changeRequestHost(identity, kind); if (filter.host !== undefined && host !== filter.host.toLowerCase()) { continue; } @@ -838,19 +893,20 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: repositoryKey ?? normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: + repositoryKey ?? + sourceControlDefinitions.repositoryKeyFromRemote(`https://${host}/${repository}`), repositoryKey: - repositoryKey === null ? null : canonicalRepositoryKey(repositoryKey.toLowerCase()), + repositoryKey === null + ? null + : sourceControlDefinitions.canonicalRepositoryKey(repositoryKey.toLowerCase()), }); } return { supported, unimplemented, viewerRoots }; }), ); - /** - * Azure derives its organization from the checkout, so it requires a matching - * repository. - */ + // Azure derives organization from the checkout; require the matching repository. const requireProject = (ref: PullRequestRef): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( Effect.flatMap(({ supported }): Effect.Effect => { @@ -871,11 +927,13 @@ export const make = Effect.gen(function* () { }), ); } - const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); - // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare - // the complete repository identity before narrowing those checkouts by host. + const referenceKey = `${host}/${repository}`.toLowerCase(); + const repositoryKey = sourceControlDefinitions.canonicalRepositoryKey(referenceKey); + // A host that spells one repository several ways (Azure's SSH and legacy clone hosts) + // can differ from the browser URL's host. Compare the complete repository identity + // before narrowing those checkouts by host. return listWorkspaceProjects( - repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + sourceControlDefinitions.findByRepositoryKey(referenceKey) ? {} : { host }, ).pipe( Effect.flatMap(({ supported }) => { const onHost = supported.filter((candidate) => candidate.host === host); @@ -892,11 +950,9 @@ export const make = Effect.gen(function* () { onHost.find((candidate) => candidate.repositoryKey === null); if (route === undefined) { return Effect.gen(function* () { - const provider = ["github.com", "bitbucket.org", "codeberg.org"].includes(host) - ? detectSourceControlProviderFromRemoteUrl(`https://${host}/${repository}.git`) - : null; - const api = provider === null ? null : registry.get(provider.kind); - if (api !== null) { + const publicHost = sourceControlDefinitions.findByPublicHost(host); + const api = publicHost === undefined ? null : registry.get(publicHost.kind); + if (api?.readsPublicHostWithoutCheckout) { const selected = yield* projects.getShell(ref.projectId).pipe( Effect.mapError( (cause) => @@ -914,7 +970,9 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlDefinitions.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), repositoryKey: null, }; } @@ -929,7 +987,9 @@ export const make = Effect.gen(function* () { : { ...route, repository, - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlDefinitions.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), }, ); }), @@ -949,12 +1009,7 @@ export const make = Effect.gen(function* () { }; }); - /** - * Every write goes through it: the page hides what a viewer may not do, and a request that - * arrived without passing through the page — or after the access behind it was withdrawn — must - * not be handed to a provider on the client's word. Read freshly for that reason, rather than - * taken from whatever the detail said when the page loaded. - */ + // Access can change after page load; recheck every write. const viewerPermissionsOf = ( project: SupportedProject, ref: PullRequestRef, @@ -1032,8 +1087,7 @@ export const make = Effect.gen(function* () { }, { capacity: VIEWER_CACHE_CAPACITY, - // This short entry exists to keep simultaneous cold page reads on one in-flight lookup; - // failures remain retryable. + // Share cold-page lookups briefly; keep failures retryable. timeToLive: (exit) => Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero, }, @@ -1054,16 +1108,12 @@ export const make = Effect.gen(function* () { } const forHost = projects.filter((project) => project.host === host); const api = forHost[0]!.api; - // Every checkout on the host, not just the ones that survived de-duplication: one - // unreadable worktree would otherwise report the whole host as signed out. + // Include deduplicated checkouts too; one unreadable worktree must not mark the whole host signed out. const roots = viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); if (options?.allowPaused === true) return Cache.get(viewerFlights, key); - // The pause is checked here rather than inside the lookup, so that it holds back the - // callers nobody is waiting on without splitting the flight they share with a press. - // A failed lookup is held nowhere, so letting a background read through would spawn - // this host's CLI on every refresh for as long as the pause lasted, and re-extend it. + // Check pauses outside the shared lookup so background reads cannot spawn repeated failing CLIs and extend the pause. return rateLimits.check({ provider: api.kind, host }).pipe( Effect.flatMap(() => Cache.get(viewerFlights, key)), Effect.catch((error) => @@ -1086,10 +1136,7 @@ export const make = Effect.gen(function* () { { concurrency: REPOSITORY_CONCURRENCY }, ); - /** - * `checks` is absent because no listed row carries its check state: that one filter is - * the host's alone, and a row nobody narrowed stays rather than being guessed at. - */ + // Only the host can filter checks; listed rows have no check state. const matchesRowFilters = ( item: ProviderChangeRequest, filters: PullRequestListFilters | undefined, @@ -1154,13 +1201,6 @@ export const make = Effect.gen(function* () { }; }; - // A repository that has appeared in a host search is known to be indexed there. Empty - // authored/reviewing searches for that same repository are therefore real empty answers, not - // a reason to issue the two-command per-repository fallback again. - const searchVisibleAt = new Map(); - const searchVisibilityKey = (host: string, repository: string) => - `${host}\n${repository.trim().toLowerCase()}`; - const listUncached: PullRequestService["Service"]["list"] = Effect.fn( "PullRequestService.listUncached", )(function* (input) { @@ -1189,7 +1229,7 @@ export const make = Effect.gen(function* () { actions: capabilities?.actions ?? [], projectCount: projectCounts.get(result.host) ?? 1, configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), + detail: result.error === null ? null : providerDetail(requirementOf, result.error), }; }), ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ @@ -1370,75 +1410,53 @@ export const make = Effect.gen(function* () { : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), }).pipe( observeRead, - Effect.flatMap(({ value: page, observedAt }) => - Effect.flatMap(Clock.currentTimeMillis, (now) => { - const rows = new Map>(); - for (const [key, visibleAt] of searchVisibleAt) { - if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { - searchVisibleAt.delete(key); - } - } - for (const item of page.items) { - const key = item.repository.trim().toLowerCase(); - const held = rows.get(key); - if (held === undefined) rows.set(key, [item]); - else held.push(item); - searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); - } - const boundary = page.items.reduce( - (oldest, item) => - oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, - null, - ); - return Effect.forEach( - chunk, - (project): Effect.Effect => { - const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; - // GitHub does not index every repository for search — a renamed one answers for - // its old name with silence rather than with an error — so a repository the - // search said nothing at all about is read on its own, once, before it is - // believed. Only on its first slice: after that it has a boundary to carry on - // from, and silence past one means the rows are older rather than absent. - const lastVisible = searchVisibleAt.get( - searchVisibilityKey(project.host, project.repository), - ); - const searchIsKnownVisible = - !page.truncated && - lastVisible !== undefined && - now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); - if ( - fetched.length === 0 && - cursorOf(project) === undefined && - !searchIsKnownVisible - ) { - return readRepository(project); - } - const cursorHere = cursorOf(project); - const items = - cursorHere === undefined - ? fetched - : fetched.filter( - (item) => - item.updatedAt !== cursorHere.updatedBefore || - !cursorHere.seenAt.includes(item.number), - ); - return Effect.succeed({ - key: project.cursorKey, - entries: items - .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer, observedAt })), - errors: [], - truncated: page.truncated, - nextCursor: - page.truncated && boundary !== null - ? listCursorAt(cursorHere, boundary, fetched, items.length) - : null, - }); - }, - { concurrency: REPOSITORY_CONCURRENCY }, - ); - }), - ), + Effect.flatMap(({ value: page, observedAt }) => { + const rows = new Map>(); + for (const item of page.items) { + const key = item.repository.trim().toLowerCase(); + const held = rows.get(key); + if (held === undefined) rows.set(key, [item]); + else held.push(item); + } + const unresolved = new Set( + (page.unresolved ?? []).map((repository) => repository.trim().toLowerCase()), + ); + const boundary = page.items.reduce( + (oldest, item) => + oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, + null, + ); + return Effect.forEach( + chunk, + (project): Effect.Effect => { + const key = project.repository.trim().toLowerCase(); + const fetched = rows.get(key) ?? []; + if (fetched.length === 0 && unresolved.has(key)) return readRepository(project); + const cursorHere = cursorOf(project); + const items = + cursorHere === undefined + ? fetched + : fetched.filter( + (item) => + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), + ); + return Effect.succeed({ + key: project.cursorKey, + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer, observedAt })), + errors: [], + truncated: page.truncated, + nextCursor: + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) + : null, + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + }), Effect.catch(separately), ); }; @@ -1511,7 +1529,7 @@ export const make = Effect.gen(function* () { host, }) .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); - return { ...identity, host, provider: "github" as const }; + return { ...identity, host, provider: api.kind }; }); const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = Effect.fn( @@ -1521,7 +1539,7 @@ export const make = Effect.gen(function* () { const rejected = () => new PullRequestOperationError({ operation: "routeIdentity", - detail: "The GitHub account could not be verified before starting the operation.", + detail: "The signed-in account could not be verified before starting the operation.", }); const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); const api = registry.get(project.api.kind); @@ -1866,11 +1884,7 @@ export const make = Effect.gen(function* () { const scope = yield* Effect.scope; const runFork = Effect.runForkWith(context); - /** - * A lookup that failed is refused rather than answered as the unnamed reader: a momentarily - * signed-out CLI would otherwise hide every tick this reader has made and file the next press - * under rows that are orphaned once it recovers. - */ + // Refuse failed viewer lookups; unnamed fallbacks hide marks and orphan subsequent writes. const requiredViewerOf = ( project: SupportedProject, operation: string, @@ -1929,9 +1943,7 @@ export const make = Effect.gen(function* () { }), ); } - // A strategy the host does not offer must be refused rather than passed on: every - // provider maps an unrecognised method to its own default, so asking Azure DevOps to - // rebase would quietly merge instead of failing. + // Refuse unsupported strategies; providers silently map unrecognized methods to their default. if ( input.mergeMethod !== undefined && !project.api.capabilities.mergeMethods.includes(input.mergeMethod) @@ -2124,12 +2136,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Neither is a question a permission read answers: every host lets the person who wrote - * something rewrite it whatever access they have otherwise, and none of them reports that as a - * permission — so a check here could only guess, and a wrong guess takes the control away from - * the one person certain to be allowed. - */ + // Hosts let authors edit without reporting that permission; checking it would wrongly deny access. const update: PullRequestService["Service"]["update"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -2324,10 +2331,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Every host with reactions takes one from whoever can read the change request, so there - * is no access left to check that reading it has not already settled. - */ + // Hosts permit reactions from any reader; reading the PR already checks access. const setReaction: PullRequestService["Service"]["setReaction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -2650,8 +2654,7 @@ export const make = Effect.gen(function* () { return Effect.flatMap(Clock.currentTimeMillis, (now) => { const snapshot = held.get(key); if (snapshot === undefined || now - snapshot.at > staleMs) return recorded; - // Run as its own fiber rather than a child: the caller is answered and gone before the - // refresh lands. + // Use an independent fiber; refresh outlives the answered caller. return revalidate(recorded).pipe(Effect.as(snapshot.value)); }); }; @@ -2718,8 +2721,7 @@ export const make = Effect.gen(function* () { const lastGoodSummary = makeLastGoodRead(DETAIL_CACHE_CAPACITY); const lastGoodDetail = makeLastGoodRead(DETAIL_CACHE_CAPACITY); - // The counter is shared and monotonic so a scope re-entering `refEpochs` after eviction can - // never mint a key an old entry still has. + // A shared monotonic counter prevents evicted scopes reusing epochs held by old entries. let epochCounter = 0; let listingsEpoch = 0; const refEpochs = new Map(); @@ -2750,8 +2752,6 @@ export const make = Effect.gen(function* () { } mergedEpochs.set(scope, projectEpochs.get(ref.projectId) ?? projectEpochFloor); }; - // Keys carry the reference back out of the cache loader, so the slot layout is shared with - // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: CredentialRef) => JSON.stringify([ refEpoch(ref), @@ -3077,8 +3077,7 @@ export const make = Effect.gen(function* () { }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => supersedesReading(lastGoodSummary.peek(key), next); - // The newest state a detail read found per pull request, whatever cache epoch or credential - // it read under. Summary reads stay out: they would hide a change from the detail read after. + // Keep newest detail states across epochs/credentials; summary reads would mask subsequent detail changes. const detailStates = new Map(); const noteDetailReading = (ref: PullRequestRef, next: PullRequestSummary) => Effect.suspend(() => { @@ -3105,8 +3104,7 @@ export const make = Effect.gen(function* () { }) : Effect.void; }); - // The last state each routed read reported, kept apart from `detailStates`: an unverified report - // must never outrank a reading this environment made itself. + // Keep routed reports separate; unverified reports must not outrank this environment's reads. const reportedStates = new Map< string, { @@ -3149,9 +3147,7 @@ export const make = Effect.gen(function* () { ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); - // Skip the write when that read is older than a later strict summary — - // display reuse would otherwise keep the regression and never ask the host - // again. + // Skip reads older than strict summaries; caching them would preserve regressions without asking the host again. const read = Cache.get(detailCache, key).pipe( Effect.tap((value) => { if (value.state === "merged") noteMerged(input); @@ -3251,13 +3247,9 @@ export const make = Effect.gen(function* () { }, }, ); - /** - * Both diff holders drop an expired value only when its own key is read again, and a diff's key - * moves with every push, so a closed page's patches would stay held until the server restarted. - */ + // Diffs expire only when their keys are reread; push-dependent keys leave closed pages' patches retained. let diffSweepPending = false; - // Forked from the service's own context, in one step with the flag: a caller's interruption - // cannot strand the flag, and its request is not kept alive by the wait. + // Fork in service context atomically with the flag to avoid stranded flags and retained caller requests. const armDiffSweep = () => { if (diffSweepPending) return; diffSweepPending = true; @@ -3314,8 +3306,7 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), }, ); - // Canonicalised before it is keyed: both epochs are bumped against the remote's own spelling, - // so a reference keyed as the client spelled it would never see a refresh or a press. + // Key canonical refs to match epochs bumped with the remote's spelling. const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => canonicalRef(input).pipe( Effect.flatMap((ref) => @@ -3497,10 +3488,7 @@ export const make = Effect.gen(function* () { ); }); - /** - * The file names in each diff slice, keyed as the slice is. Names stay small when a patch is too - * large to cache, so expanding a file in a large change request does not fetch its diff again. - */ + // Cache small filename lists even when patches exceed limits, avoiding diff refetches on file expansion. const diffFiles = (() => { const held = new Map< string, @@ -3539,10 +3527,6 @@ export const make = Effect.gen(function* () { }); return { read: credentialCached(read), fileKey }; })(); - /** - * Whether the change request, or the one commit asked for, changes this file under these names. - * Walks the same slices the reader was shown, reading only their file names once held. - */ const changesFile = (input: PullRequestDiffFileContentsInput) => { const wanted = diffFiles.fileKey(input.oldPath, input.newPath); const walk = (cursor: string | undefined): Effect.Effect => @@ -3563,9 +3547,7 @@ export const make = Effect.gen(function* () { ); return walk(undefined); }; - // Only the files the change request itself changes: these are read with the project's own - // credential, and a reader who may look at the change request may not thereby read the rest of - // the repository. + // PR read access does not grant arbitrary repository reads with the project credential; restrict to changed files. const diffFileContents: PullRequestService["Service"]["diffFileContents"] = (input) => changesFile(input).pipe( Effect.flatMap((changed) => diff --git a/apps/server/src/pullRequest/http.ts b/apps/server/src/pullRequest/http.ts index 746e29a506a6..0e9f353900eb 100644 --- a/apps/server/src/pullRequest/http.ts +++ b/apps/server/src/pullRequest/http.ts @@ -6,7 +6,7 @@ import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/htt import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as PullRequestService from "./PullRequestService.ts"; -/** The patch is often the largest PR payload and benefits from HTTP compression and flow control. */ +// Large patches benefit from HTTP compression and flow control. export const layer = HttpApiBuilder.group( EnvironmentHttpApi, "pullRequests", diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index fa51017ebcd5..c7a2b01946a2 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -1,7 +1,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { PullRequestLinkedThreadsResult, PullRequestOperationError, diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts index 2b21e20d15fc..48db0795edba 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts @@ -1,19 +1,17 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; describe("pullRequestSyncKey", () => { const reference = { projectId: ProjectId.make("project"), repository: "web", number: 7 }; - it.each([ - "dev.azure.com/org/project/_git/web", - "ssh.dev.azure.com/v3/org/project/web", - "vs-ssh.visualstudio.com/v3/org/project/web", - "org.visualstudio.com/defaultcollection/project/_git/web", - ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => { + // Every spelling of the key reaches dev.azure.com through the definition's canonical key, + // which the Azure DevOps package tests. + it("resolves hostless and checkout-host references named by the bare repository", () => { + const canonicalKey = "ssh.dev.azure.com/v3/org/project/web"; const identity = { canonicalKey, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), name: "web", displayName: canonicalKey.split("/").slice(1).join("/"), locator: { diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 3b5ecc380d03..1e74563fd610 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -1,43 +1,39 @@ -import { - pullRequestHostOf, - type PullRequestRef, - type RepositoryIdentity, - SourceControlProviderKind, - type ThreadPullRequestKey, -} from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import type { PullRequestRef, RepositoryIdentity, ThreadPullRequestKey } from "@t3tools/contracts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; +import { normalizeThreadPullRequestKey } from "../sourceControl/threadPullRequests.ts"; export function pullRequestSyncKey( reference: PullRequestRef, identity?: RepositoryIdentity | null, ): ThreadPullRequestKey | null { - if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) { + const host = identity + ? sourceControlDefinitions.changeRequestHost(identity, identity.provider) + : undefined; + // A host whose tools select a repository by something other than its path (Azure DevOps takes + // the bare name) can only key such a reference through the project's own repository. + const definition = identity?.provider + ? sourceControlDefinitions.find(identity.provider) + : undefined; + if (identity && definition?.repositorySelector && !reference.repository.includes("/")) { if ( reference.repository.toLowerCase() !== - sourceControlRepositorySelector(identity)?.toLowerCase() || - (reference.host !== undefined && - reference.host.toLowerCase() !== - pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops"))) + definition.repositorySelector(identity)?.toLowerCase() || + (reference.host !== undefined && reference.host.toLowerCase() !== host) ) return null; - const [host, ...repository] = identity.canonicalKey.split("/"); - if (!host || repository.length === 0) return null; + const [projectHost, ...repository] = identity.canonicalKey.split("/"); + if (!projectHost || repository.length === 0) return null; return normalizeThreadPullRequestKey({ - host, + host: projectHost, repository: repository.join("/"), number: reference.number, }); } - const host = - reference.host ?? - (identity - ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) - : undefined); - return host === undefined + const resolvedHost = reference.host ?? host; + return resolvedHost === undefined ? null : normalizeThreadPullRequestKey({ - host, + host: resolvedHost, repository: reference.repository, number: reference.number, }); diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index fc2913e3efd4..596808eb46a0 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -46,10 +46,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const heldFileRevisions = new Map(); const refreshingFileRevisions = new Set(); - /** - * Spelled from the project rather than the reference, since a reference arrives however - * the client spelled it while the epoch is bumped against the remote's own spelling. - */ + // Epochs use remote spelling; canonicalize client refs before keying. const fileRevisionsKey = (project: SupportedProject, ref: PullRequestRef) => [ refEpoch({ ...ref, host: project.host, repository: project.repository }), @@ -92,8 +89,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const oldest = heldFileRevisions.keys().next().value; if (oldest !== undefined) heldFileRevisions.delete(oldest); } - // The entry is only as fresh as its oldest revision: stamping it with `now` on a partial - // answer would let an old revision ride past the point it should have been re-read. + // Partial answers retain the oldest timestamp so carried revisions cannot outlive their freshness. const stamped = [...revisions.keys()].every((path) => answer.revisions.has(path)) ? at : (carried?.at ?? at); @@ -110,11 +106,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { return paths.every((path) => held.asked.has(path)) ? held : null; }; - /** - * `held` answers from a stale value and refetches off the critical path, since a badge a - * moment behind beats a page that won't paint until the host answers; `fresh` is for the - * press itself, which must not store a revision the head already moved off. - */ + // Held reads refresh off the critical path; presses need fresh revisions to avoid saving obsolete heads. const fileRevisionsOf = ( project: SupportedProject, ref: PullRequestRef, @@ -124,8 +116,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { ): Effect.Effect | null, PullRequestError> => { const read = project.api.getFileRevisions; if (read === undefined) return Effect.succeed(null); - // Suspended, so a held answer costs the host nothing: a provider is free to do its work as - // the request is built rather than as the effect is run. + // Suspend request construction too; providers may start work while building requests. const fetch = Effect.suspend(() => { const key = fileRevisionsKey(project, ref); return read({ @@ -147,8 +138,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { return Effect.succeed(held.revisions); if (freshness === "fresh") return fetch; if (refreshingFileRevisions.has(key)) return Effect.succeed(held.revisions); - // Its own fiber rather than a child: the caller has been answered and is gone before this - // lands. + // Refresh independently; it outlives the answered caller. return Effect.sync(() => { refreshingFileRevisions.add(key); runFork( @@ -174,10 +164,7 @@ export interface Dependencies extends FileRevisionsDependencies { ) => Effect.Effect; } -// A plain factory rather than a `Context.Service` (against the preference in -// `.repos/effect-smol/LLMS.md`): the held revisions, refresh set, and write gates are only correct -// at one instance per service, and a layer provided at two points would give two of each behind -// one epoch counter. +// Use one factory instance per service; multiple layers would duplicate revision state and gates behind one epoch. export const make = (dependencies: Dependencies) => { const { filesViewedStore, requireProject, requiredViewerOf, toPullRequestError } = dependencies; const { fileRevisionsOf } = makeFileRevisions(dependencies); @@ -237,10 +224,7 @@ export const make = (dependencies: Dependencies) => { }, ); - /** - * A tick's host round trip is slower than an untick's, so unordered presses could finish - * out of order and leave a stale tick standing over a later untick. - */ + // Serialize presses so slower tick lookups cannot overwrite later unticks. const filesViewedGates = new Map< string, { readonly gate: Semaphore.Semaphore; pending: number } @@ -251,9 +235,7 @@ export const make = (dependencies: Dependencies) => { number: number, write: Effect.Effect, ) => - // Suspended rather than generated, so finding the gate, putting it in and taking a place in - // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert - // lets two presses each make a gate of their own and neither wait on the other. + // Atomically find, register and queue on gates; yielding during creation lets concurrent presses use separate gates. Effect.suspend(() => { const key = `${project.project.id} ${project.remote} ${number}`; const held = filesViewedGates.get(key); @@ -294,9 +276,7 @@ export const make = (dependencies: Dependencies) => { yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), - // A path left out of the answer stores with no baseline, not the empty revision, since - // the empty revision is itself an answer and would misreport the file once it turns - // out to have a version after all. + // Missing paths have null baselines; empty revision means confirmed deletion. files: input.files.map((file) => ({ path: file.path, revision: revisions?.get(file.path) ?? null, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d1ad9e7a58a6..b5f400dc3432 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -215,6 +215,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( settleThread: unused, interruptThread: unused, stopWorkflow: () => Effect.die("unused stopWorkflow"), + awaitBackgroundTasks: unused, stopDelegatedTasks: unused, getThreadEventSequence: unused, recoverDelegatedTask: unused, @@ -418,14 +419,14 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); - it.effect("keeps settled live work until its monitor finishes", () => + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); const held = shell({ status: "completed", settledOverride: "settled", - pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" }], + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor", awaited: true }], }); yield* Ref.set(currentShell, held); yield* relay.publishThread(THREAD_ID); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index c6a61817f69b..93f3a73b2192 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -104,6 +104,7 @@ function shouldPublishAgentAwarenessEvent( return true; // Unsettling must not re-alert a dismissed result. case "thread.unsettled": + case "thread.guest-share-updated": case "thread.snoozed": case "thread.unsnoozed": case "thread.auto-settle-set": @@ -147,8 +148,7 @@ export const makeAgentAwarenessPublishWorker = Effect.fnUntraced(function* ( const enqueue = (threadId: ThreadId) => Effect.suspend(() => { if (queued.has(threadId)) return Effect.void; - // Removing the ID when processing starts allows one new queue entry - // for updates received while the current snapshot is being published. + // Remove queued IDs when processing starts so publication can queue one later update. queued.add(threadId); return worker.enqueue(threadId); }).pipe(Effect.uninterruptible); @@ -359,7 +359,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { .map((thread) => thread.id); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -409,9 +409,7 @@ export const make = Effect.gen(function* () { let cachedRelayClient: Effect.Success> | undefined; let publishedRelayConfig: NonNullable> | undefined; - // The confirming publish is re-enqueued through the same drainable worker as - // every other publish, so a confirmed tombstone can never race an in-flight - // live update; a recovered state clears the deadline. + // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< @@ -511,17 +509,13 @@ export const make = Effect.gen(function* () { }); }); - // Per-thread shell read: this publish runs for every activity-relevant - // domain event, so materializing the full shell here would make the cost - // of one thread's activity proportional to how many threads exist. + // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* threads.getThreadShell(threadId); if ( threadShell?.lineage.relationshipToParent === "subagent" && !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) ) { - // Subagents never project activity, so the relay holds no row to clear. - // Their events would otherwise publish a tombstone each, and every - // publish re-delivers the user's aggregate. + // Subagents have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. return; } const thread = @@ -546,10 +540,7 @@ export const make = Effect.gen(function* () { if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; } if (publishedStateByThread.get(threadId) === publishIdentity) { - // The projection is back at (or never left) the last published state, so - // any pending deferred confirmation is moot. Leaving the deadline in - // place would let a much later transient null find it already expired - // and publish a tombstone immediately, skipping the deferral window. + // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, @@ -559,11 +550,8 @@ export const make = Effect.gen(function* () { return; } - // - null (tombstone) while the previous published state was live: deletes - // the thread from every armed card mid-conversation. - // - completed as the thread's FIRST published state: sessions boot at - // "ready" before their first turn, which projects as completed for an - // instant and sends a spurious Done notification at thread birth. + // Confirm tombstones over live state to avoid removing active cards. + // Confirm first-state completion because sessions briefly project completed before their first turn. const requiresConfirmation = (snapshot.state === null && publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || @@ -657,8 +645,7 @@ export const make = Effect.gen(function* () { const attempts = publishRetries.get(threadId)?.attempts ?? 0; const delayMs = RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS[attempts]; if (delayMs === undefined) { - // Keep the exhausted budget until fresh activity or a successful publish - // clears it; an old confirmation timer must not start another retry series. + // Retain exhausted budgets until activity or success resets them; old timers must not restart retries. yield* Effect.logWarning("agent activity publish retry budget exhausted", { threadId }); return; } diff --git a/apps/server/src/relay/HeldHooksWaker.ts b/apps/server/src/relay/HeldHooksWaker.ts index 5046d62dccfd..9fee885aa819 100644 --- a/apps/server/src/relay/HeldHooksWaker.ts +++ b/apps/server/src/relay/HeldHooksWaker.ts @@ -33,8 +33,7 @@ export const layer = Layer.effectDiscard( ), ); yield* runtime.tunnelConnected.pipe( - // cloudflared registers several connections per (re)connect within a - // few seconds; they are one wake. + // Coalesce cloudflared's several connections per reconnect into one wake. Stream.debounce("3 seconds"), Stream.runForEach(() => wake), Effect.forkScoped, diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index ddd31817c4ce..4fec32dd76c1 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -322,7 +322,7 @@ export function requireDesktopTelemetryWriteProgress( : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes })); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { const config = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -458,8 +458,7 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") if (config.desktopTelemetryFd !== undefined) { const fd = config.desktopTelemetryFd; - // The parent waits for this server before closing its pipe. Socket reads - // can be cancelled; filesystem reads would keep process.exit waiting. + // The parent waits before closing; use cancellable socket reads so filesystem reads cannot block exit. const readable = yield* Effect.acquireRelease( Effect.try({ try: () => new NodeNet.Socket({ fd, readable: true, writable: false }), diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 1cc53e1de2f1..4788689046af 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -32,8 +32,7 @@ function darwinAvailableMemory(output: string): number | null { const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; if (!pageSize || !free || !inactive || !speculative) return null; - // vm_stat subtracts speculative pages from its printed "Pages free" count. - // Adding them here counts each reclaimable page once; purgeable pages overlap. + // vm_stat excludes speculative from free; add it once, without overlapping purgeable pages. const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; } diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 6f4a019a8ac7..55eeb2f85519 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -361,7 +361,7 @@ function canCommandNativeTelemetrySidecar( return hasHandle && (status === "healthy" || status === "degraded"); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 6eb0a5fd2861..232d89b7aa50 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -8,8 +8,7 @@ import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; -// The override checks POSIX exec bits on a real file under a linux platform -// mock; NTFS never reports those bits, so the check cannot be satisfied there. +// NTFS lacks POSIX exec bits required by this Linux-mocked override check. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; describe("ResourceMonitorBinary", () => { diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index d582698d9ce3..f249197d8510 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -189,8 +189,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); if (Option.isSome(stat)) { - // Directories are executable-looking on win32, where the exec-bit test - // below is skipped, and only fail later at spawn. + // Windows skips exec-bit checks, so directories would otherwise pass until spawn. const unusable = stat.value.type === "Directory" || (platform !== "win32" && (stat.value.mode & 0o111) === 0); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts index 2af6d98da0d1..7516640dca92 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -142,7 +142,7 @@ function buildHealth(input: { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () { const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient; const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index a9fb05ab1439..4df56454cfc5 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -23,7 +23,6 @@ function layer(input: { readonly previousWorktreesDirectories?: ReadonlyArray; readonly projectRoots?: ReadonlyArray | "unavailable"; readonly threadWorktrees?: ReadonlyArray<{ worktreePath: string; deleted?: boolean }>; - /** What `git worktree list` reports for every project. */ readonly gitWorktrees?: ReadonlyArray; }) { const projectRoots = input.projectRoots ?? []; @@ -175,7 +174,6 @@ describe("ReviewService", () => { assert.strictEqual((yield* review(projectRoot, [projectRoot])).cwd, projectRoot); assert.strictEqual((yield* review(projectChild, [projectRoot])).cwd, projectChild); - // A child whose name starts with `..` is still inside. const dottedChild = `${projectRoot}/..feature`; yield* fs.makeDirectory(dottedChild); assert.strictEqual((yield* review(dottedChild, [projectRoot])).cwd, dottedChild); @@ -196,11 +194,9 @@ describe("ReviewService", () => { const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); const project = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-project-" }); - // Checkouts outside every managed folder, as `git worktree add ../feature` makes. const bound = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-bound-" }); const deleted = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-deleted-" }); const unbound = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-unbound-" }); - // A client can set a thread's path to any folder; git doesn't list this one. const claimed = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-claimed-" }); yield* fs.makeDirectory(`${bound}/sub`); @@ -219,9 +215,7 @@ describe("ReviewService", () => { { worktreePath: bound }, { worktreePath: claimed }, { worktreePath: deleted, deleted: true }, - // A relative path would resolve against the server's own cwd. { worktreePath: "t3-review-relative" }, - // A filesystem root would make every folder look bound. { worktreePath: "/" }, ], }), diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 488cc84be0a7..48c412a4bbc7 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -32,7 +32,7 @@ export class ReviewService extends Context.Service< } >()("t3/review/ReviewService") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/rpcInitialItems.ts b/apps/server/src/rpcInitialItems.ts index 172a3a897a32..6f3409858920 100644 --- a/apps/server/src/rpcInitialItems.ts +++ b/apps/server/src/rpcInitialItems.ts @@ -6,8 +6,7 @@ export function rpcInitialItems(items: ReadonlyArray): Stream.Stream { return Stream.unwrap( Effect.sync(() => { const initial = pending; - // concat keeps its initial stream definition alive during the live tail. - // That definition must no longer own delivered snapshots or replay rows. + // concat retains the initial stream during live tailing; release delivered snapshots and rows. pending = undefined; return initial === undefined ? Stream.empty : Stream.fromIterable(initial); }), diff --git a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts index abf7c148acc7..0f33869c387e 100644 --- a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts +++ b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts @@ -1,9 +1,4 @@ -/** - * The relay's delivery id, receive time and trace context arrive as plain - * headers, and the webhook URL can also be called directly, so they are only - * trusted alongside a proof the relay signed with its mint key. A request - * without a valid proof is handled as a direct request. - */ +// Trust relay headers only with a mint-key-signed proof; otherwise handle the webhook as a direct request. import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay"; import { normalizeRelayIssuer, diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index 4cabb3539336..d97ea43ae6ba 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -15,8 +15,6 @@ export function nextScheduledRunAt( ): DateTime.DateTime | null { if (schedule.type === "webhook") return null; if (schedule.type === "interval") { - // Persisted rows created before the one-minute floor remain readable, but - // they must not retain their old high-frequency execution rate. // Intervals advance elapsed time, including across daylight-saving changes. return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS)); } @@ -51,9 +49,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul } if (a.type === "webhook") return b.type === "webhook"; if (b.type !== "fixed_time") return false; - // The contract accepts padded and unpadded hours ("9:00" and "09:00"), so - // compare the parsed time — string equality would treat a format-only edit - // as a schedule change and recompute the pending run. + // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); const bTime = parseTimeOfDay(b.timeOfDay); return ( diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 466af14e7f22..c1dcdd22abba 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -179,8 +179,6 @@ it.effect( yield* insertRow(sql, { ...row, enabled: 1 }, now); } - // it.effect freezes the clock; pin it just past the seeded due time so - // the poll sees the rows as due. yield* TestClock.setTime(Date.parse(now) + 1_000); const dispatched = yield* Ref.make(0); const lastDispatched = yield* Deferred.make(); @@ -215,8 +213,7 @@ it.effect( ), ), ); - // The poll loop delays before its first pass, so the frozen test - // clock must advance once to trigger it. + // The poll delays before its first pass; advance the frozen clock once. yield* TestClock.adjust("6 seconds"); yield* Deferred.await(lastDispatched); const inflight = yield* sql<{ diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index a4714e3ae417..7e39452c2b52 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -78,7 +78,7 @@ const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; const WEBHOOK_MAX_QUEUED_PER_TASK = 20; -/** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ +// Enforce the relay's per-task/minute limit here too; tunnel hostnames are public. const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ "accepted", @@ -256,11 +256,7 @@ export class ScheduledTaskService extends Context.Service< readonly getWebhookDelivery: ( input: ScheduledTaskGetWebhookDeliveryInput, ) => Effect.Effect; - /** - * Verifies, logs and dispatches one webhook request. Returns as soon as the - * delivery is logged; the run itself continues in the background so senders - * with short timeouts get their answer immediately. - */ + /** Verifies, logs and dispatches; returns after logging while the run continues in the background. */ readonly triggerWebhook: ( request: WebhookTriggerRequest, ) => Effect.Effect; @@ -286,8 +282,7 @@ function nextRunAt( from: DateTime.DateTime, ): string | null { if (!task.enabled) return null; - // A stored interval can decode yet overflow the representable DateTime - // range; an unrepresentable occurrence means the task has no next run. + // Decoded intervals can overflow DateTime; unrepresentable occurrences have no next run. try { const next = nextScheduledRunAt(task.schedule, from); return next !== null && Number.isFinite(DateTime.toEpochMillis(next)) ? iso(next) : null; @@ -377,9 +372,7 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct const decoded = yield* Effect.result(decodeRow(row)); if (Result.isSuccess(decoded)) { const task = decoded.success; - // next_run_at is a freeform string at the schema level; a stored value - // that cannot parse as a DateTime would defect the poll below, so the - // row is skipped here like any other corrupt row. + // next_run_at is freeform; skip unparseable values before they defect the poll. if (task.nextRunAt === null || Option.isSome(DateTime.make(task.nextRunAt))) { tasks.push(task); } else { @@ -411,8 +404,7 @@ export const layer = Layer.effect( const webhookPermits = yield* Ref.make>( new Map(), ); - // Keyed by task id and creation time, so deliveries of a deleted task that - // finish late release their own count, never a recreated task's. + // Include creation time so late deliveries cannot decrement a recreated task's count. const webhookQueued = yield* Ref.make>(new Map()); const webhookRateWindows = yield* Ref.make>(new Map()); const webhookRejectedRateWindows = yield* Ref.make>( @@ -426,9 +418,7 @@ export const layer = Layer.effect( ReadonlyMap> >(new Map()); const activeRuns = yield* Ref.make>(new Set()); - // Sliding(1) coalesces the dirty-signal: every notification triggers a - // full list() re-emit anyway, so a slow subscriber only ever needs the - // latest signal — an unbounded backlog would just grow memory. + // 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); @@ -534,11 +524,8 @@ export const layer = Layer.effect( return task; }); - // Run-state columns (last_run_*, run_count) are intentionally absent from - // the conflict clause: they are owned by the run transitions below, and a - // concurrent settings save must not overwrite an in-flight increment. - // Check existence in the write itself so an edit cannot undo a deletion - // that landed after upsert loaded the previous task. + // Run transitions own run-state columns; settings saves must not overwrite them. + // Check existence in the write so edits cannot undo concurrent deletion. const saveTask = ( task: ScheduledTask, requireExisting: boolean, @@ -691,12 +678,9 @@ export const layer = Layer.effect( ), ); - // Best-effort escape hatch: if anything fails between markRunning and - // markCompleted, write a full terminal record so runDueTasks neither skips - // the task forever (it filters out 'running' rows) nor re-fires it - // immediately: the dispatch may already have gone out, so next_run_at must - // advance. Pending webhook attempts count only with their completion state; - // other attempts count here. + // A failure between markRunning and markCompleted must still write a terminal record: + // runDueTasks skips 'running' rows forever, and the dispatch may already have gone out, + // so next_run_at must advance. Pending webhook attempts count only with their completion. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { const now = yield* localNow; @@ -754,9 +738,7 @@ export const layer = Layer.effect( const startedAt = yield* localNow; const startedAtIso = iso(startedAt); - // The in-memory snapshot may be stale: re-read before touching run - // state. The task may have been deleted, paused, or postponed since - // the poll loaded it — none of those may fire. + // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { if (webhook !== undefined) { @@ -786,9 +768,7 @@ export const layer = Layer.effect( ) { return { task: active, threadId: null }; } - // A queued delivery must not run a task that was paused, deleted and - // recreated under the same id, or switched to another trigger while - // it waited for its turn. + // Reject queued deliveries after pause, same-id recreation or trigger changes. if (webhook !== undefined) { const reason = active.createdAt !== task.createdAt @@ -828,9 +808,7 @@ export const layer = Layer.effect( // A webhook prompt was rendered from the row when the request arrived. const prompt = webhook?.prompt ?? active.prompt; - // Effect.exit (not Effect.result) so defects and interruptions in the - // dispatch are also captured and recorded as a failed run instead of - // aborting before markCompleted. + // Capture defects and interrupts too so dispatch failures still reach markCompleted. const result = active.threadId === null ? yield* Effect.exit( @@ -985,8 +963,7 @@ export const layer = Layer.effect( ); }); - // Recover from a crash or hard shutdown mid-run: rows stuck in 'running' - // would otherwise be skipped by the due-task filter forever. + // Recover crash-left running rows that the due-task filter would otherwise skip forever. yield* Effect.gen(function* () { const rows = yield* selectAllRows(); const stuck = rows.filter((row) => row.last_run_status === "running"); @@ -1050,8 +1027,7 @@ export const layer = Layer.effect( const subscribeList: ScheduledTaskService["Service"]["subscribeList"] = () => Stream.unwrap( Effect.gen(function* () { - // Subscribe before taking the snapshot so a change landing between - // the two is buffered by the subscription rather than dropped. + // Subscribe before reading to buffer concurrent changes. const subscription = yield* PubSub.subscribe(changesPubSub); return Stream.concat( Stream.fromEffect(list()), @@ -1433,8 +1409,7 @@ export const layer = Layer.effect( return sql .withTransaction( Effect.gen(function* () { - // Conditional on the task existing, so a delivery racing a delete - // cannot leave rows that a recreated task with the same id would show. + // Require the task to exist so deletes cannot leave deliveries for a recreated task. yield* sql` INSERT INTO scheduled_task_webhook_deliveries ( delivery_id, task_id, received_at, method, query, headers_json, body, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 65cecbcf4328..127f61444029 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -300,7 +300,6 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () => assert.equal(delivery.body, new TextDecoder().decode(pullRequestBody)); assert.equal(delivery.renderedPrompt, launched.initialMessage?.text); - // Each delivery gets its own frame, so a request can't predict the tag that closes it. yield* service.triggerWebhook(requestFor(task)); const next = framed.exec((yield* Queue.take(launches)).initialMessage?.text ?? "")?.[1]; assert.isDefined(next); diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index 2566349d273b..e43d9d81c930 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -17,7 +17,7 @@ import * as ScheduledTaskService from "./ScheduledTaskService.ts"; /** The relay enforces the same cap. */ export const WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; -/** Response header naming what happened to the request; the relay records it. */ +// The relay records this outcome header. const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome"; const json = (status: number, body: Record, outcome: string) => diff --git a/apps/server/src/scheduledTasks/webhookTemplate.test.ts b/apps/server/src/scheduledTasks/webhookTemplate.test.ts index 6aa23e12b036..eaee34f52da3 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.test.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.test.ts @@ -54,7 +54,6 @@ describe("renderWebhookPrompt", () => { { ...githubPullRequest, bodyText: JSON.stringify({ text }) }, "id", ); - // The adapters' pattern finds only the template's own mention. const mentions = [...rendered.prompt.matchAll(SKILL_MENTION_PATTERN)].map((match) => match[2]); assert.deepEqual(mentions, ["review"]); // Codex and Pi read `$name` after any character, digits included. diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index 84e2599dc0d7..fc43dbff921e 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -1,14 +1,5 @@ -/** - * Anyone who can call the URL chooses these values, so each one is wrapped in - * `` tags and a closing note marks the tagged text as - * untrusted request data. The caller picks a fresh random ID per request, so a - * request can't close its own frame. Mentions inside a value are broken so - * they never act; the template's own mentions still do. - * - * Credential-looking headers and query parameters are redacted wherever the - * whole set renders (`{{request}}`, `{{headers}}`, `{{query}}`), as in the - * delivery log. Naming one (`{{headers.authorization}}`) gives its raw value. - */ +// Frame untrusted values with unpredictable per-request tags and break their mentions, preserving template mentions. +// Whole-set placeholders redact credentials; named placeholders return raw values. export interface WebhookRequest { readonly method: string; @@ -56,17 +47,8 @@ export function redactQuery(query: string): string { // Trim after matching so unclosed placeholders cannot backtrack over whitespace partitions. const PLACEHOLDER = /\{\{([^{}]*)\}\}/g; -/** - * Syntax an adapter or provider CLI may act on before the model reads the - * prompt: `$name` skill mentions (any currency sign, after any character in - * Codex and Pi), `@` file, agent and plugin mentions (after punctuation or - * letters in some CLIs), a `/name` command word mid-text, and `t3-context:` - * links. A word joiner goes after every currency sign and `@`, after a `/` - * that starts a single-segment word, and inside the `t3-context` scheme and - * any `webhook-data` name, so a value can't forge a frame tag. - * Checked against Claude Code 2.1.296, Codex 0.162.1, Pi 1.2.0 and dsh - * 0.2.0-rc.2; other agents may parse more. - */ +// Break mentions before provider CLI parsing, including currency signs, embedded @, commands, context links and frame tags. +// Checked with Claude Code 2.1.296, Codex 0.162.1, Pi 1.2.0 and dsh 0.2.0-rc.2; others may parse more. const MENTION = /[\p{Sc}@]|(?<=^|\s)\/(?=[^\s/]+(?:\s|$))|t3-context(?=:)|webhook(?=-data)/giu; function parseBody(request: WebhookRequest): unknown { @@ -74,8 +56,7 @@ function parseBody(request: WebhookRequest): unknown { if (contentType.includes("application/x-www-form-urlencoded")) { return Object.fromEntries(new URLSearchParams(request.bodyText)); } - // Senders are inconsistent about content types, so any body that parses - // as JSON is addressable. + // Senders mislabel content types; accept any parseable JSON body. try { return JSON.parse(request.bodyText) as unknown; } catch { @@ -183,7 +164,6 @@ export function renderWebhookPrompt( if (!missing.includes(expression)) missing.push(expression); return ""; } - // An empty value carries no request data, so it needs no frame or note. if (text === "") return ""; if (raw) return text; framed = true; diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index 4470c257f67b..80960bbee538 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -1,9 +1,5 @@ -/** - * The user's answer goes straight to the server's secret store under a - * one-use SecretRef; orchestration only records the request and its status. - * A tool that needs the value takes the ref and consumes it, so the value - * never reaches the transcript, projections, clients, or model context. - */ +// Answers stay in the secret store behind one-use refs; orchestration records only request status. +// Consuming tools keep values out of transcripts, projections, clients and model context. import { CommandId, SecretRef, @@ -96,10 +92,8 @@ const make = Effect.gen(function* () { if (run === undefined || ThreadManagementService.isTerminalRunStatus(run.status)) { return yield* new SecretRequestError({ reason: "agent_stopped" }); } - // Store first: the card only says saved once the value is kept. Create, - // not set: a second answer racing this one must not replace the value. - // A value already there on a card still pending is an earlier save whose - // record failed and could not be cleaned up; recording it finishes that save. + // Create before recording so racing answers cannot overwrite values or show saved before storage. + // Existing values on pending cards finish earlier saves whose recording failed. if (input.answer.type === "save") { const encoded = yield* encodeStored({ projectId: records.thread.projectId, @@ -134,8 +128,7 @@ const make = Effect.gen(function* () { }) .pipe( Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })), - // The card still says pending, so the user can save again; the value - // stored above would make that retry look already answered. + // Clear failed saves so the pending card can be answered again. Effect.tapError(() => input.answer.type === "save" ? removeLogged(storeName(refFor(salt, input.threadId, item.id))) @@ -143,9 +136,7 @@ const make = Effect.gen(function* () { ), ); if (input.answer.type !== "save") return; - // A request is answered once: if the agent's wait closed the card between - // the checks above and this record, the record changed nothing. Nobody - // will receive the ref, so the value is deleted rather than left to expire. + // The wait may have closed the card; delete unrecorded answers because no one receives their refs. const recorded = yield* threadManagement .getThreadRecords(input.threadId, ["turnItems"], { turnItemTypes: ["secret_request"], @@ -180,8 +171,7 @@ const make = Effect.gen(function* () { ), ); - // get and remove are separate store calls; one consumer at a time keeps two - // concurrent calls from both reading a ref before either deletes it. + // Serialize get/remove so concurrent consumers cannot read the same one-use ref. const consumeLock = yield* Semaphore.make(1); const consume: SecretRequests["Service"]["consume"] = (input) => consumeRef(input).pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6b819011dc29..7c07852592b1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -60,6 +60,7 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlDefinitions.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import { ProviderSecretResolverLive } from "./provider/ProviderSecretResolverLive.ts"; @@ -141,6 +142,7 @@ import { } from "./cloud/config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudHttp from "./cloud/http.ts"; +import * as GuestHttp from "./guest/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; import * as ConnectReachability from "./cloud/ConnectReachability.ts"; import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; @@ -186,7 +188,7 @@ import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; import { makePersistedServerRuntimeState } from "./serverRuntimeState.ts"; -import { loopbackUrlHostForAddress } from "./startupAccess.ts"; +import { acceptsPrivateNetworkConnection, loopbackUrlHostForAddress } from "./startupAccess.ts"; import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; @@ -194,15 +196,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; -// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's -// 100-character default for one path segment. +// Escaped MCP handoff provenance can exceed find-my-way's 100-character segment default. const HTTP_ROUTER_CONFIG = { maxParamLength: 512, } as const; -// Effect's default preemptive shutdown waits 20s before finalizing request scopes. -// T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer -// already closes the websocket gracefully. +// Skip Effect's default 20 s grace; WebSocket scope finalizers already close connections gracefully. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const layerResourceAttribution = ResourceAttribution.layer; const layerApplicationObservability = EventLoopMonitor.layer.pipe( @@ -210,8 +209,7 @@ const layerApplicationObservability = EventLoopMonitor.layer.pipe( Layer.provideMerge(layerResourceAttribution), ); -// One rotating store behind both logger views, so driver instances never race -// its rotation. +// Share rotation state so driver loggers cannot race. const layerProviderEventLoggers = Layer.effect( ProviderEventLoggers.ProviderEventLoggers, Effect.gen(function* () { @@ -256,8 +254,7 @@ const layerHostPowerMonitor = HostPowerMonitor.layer.pipe( Layer.provide(layerDesktopTelemetryReceiver), ); -// Reuses DesktopTelemetryReceiverLayerLive: a fresh receiver layer here -// would open a second reader on the desktop telemetry fd. +// A fresh receiver layer would open a second reader on the desktop telemetry fd. const layerDesktopAppUpdate = DesktopAppUpdate.layer.pipe( Layer.provide(layerDesktopTelemetryReceiver), ); @@ -300,7 +297,19 @@ const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; return NodeHttpServer.layer( - () => rejectRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())), + () => { + const server = rejectRequestsUntilServing( + guardHttpResponseWriteErrors(NodeHttp.createServer()), + ); + if (config.privateNetworkOnly) { + server.on("connection", (socket) => { + if (!acceptsPrivateNetworkConnection(socket)) { + socket.destroy(); + } + }); + } + return server; + }, { host: config.host ?? "127.0.0.1", port: config.port, @@ -333,9 +342,7 @@ const layerRepositoryIdentityResolver = Layer.effect( const registry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return yield* RepositoryIdentityResolver.make({ refine: Effect.fn(function* (identity: RepositoryIdentity) { - for (const kind of SourceControlBuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS.map( - (driver) => driver.kind, - )) { + for (const { kind } of BUILT_IN_SOURCE_CONTROL_DRIVER_LIST) { const provider = yield* registry.get(kind); if (provider.refineRepositoryIdentity === undefined) continue; const refined = yield* provider.refineRepositoryIdentity({ @@ -713,6 +720,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerOtlpTracesProxyRoute, ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, + GuestHttp.layer, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, @@ -726,8 +734,7 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide( PullRequestReviewGuideService.layer.pipe(Layer.provide(PullRequestReviewGuides.layer)), ), - // Both transports consume the same service instance, so caches single-flight across clients - // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. + // Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches. Layer.provide(layerPullRequestService), Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), @@ -855,11 +862,8 @@ const layerMakeServer = Layer.unwrap( ), Effect.asVoid, ); - // The previous server is already gone, so the trial owns cleanup - // immediately; the pending-state check keeps the tunnel for normal - // commit or rollback, while the launcher's explicit-stop marker allows - // it to be released. Other runtimes wait for activation so a failed - // standby cannot tear down the active runtime's tunnel. + // Trials own cleanup immediately after the old server exits, retaining tunnels for commit/rollback unless explicitly stopped. + // Other runtimes wait for activation so failed standbys cannot tear down active tunnels. const cleanupBeforeActivation = yield* pendingServiceUpdateExists; if (cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); @@ -909,8 +913,7 @@ const layerMakeServer = Layer.unwrap( ), ) : false; - // A failed read must not end this fiber before it registers - // recovery and starts consuming recovery requests. + // Recover reads so this fiber still registers and consumes recovery requests. const desiredCliLinkMode = wantsCliLink ? yield* CloudCliState.readCliDesiredLinkMode.pipe( Effect.catch((cause) => @@ -920,8 +923,7 @@ const layerMakeServer = Layer.unwrap( ), ) : null; - // A publish-only link must not expose the host, even if a managed - // config from an earlier link is still stored. + // Publish-only links must not expose the host through old managed configs. const startedConfirmed = desiredCliLinkMode === "publish_only" ? false @@ -971,8 +973,7 @@ const layerMakeServer = Layer.unwrap( }).pipe(Effect.as({ status: "unavailable" as const })), ), ); - // Spread those registrations so an auto-update wave does not hit - // the relay all at once. + // Jitter registrations so update waves do not hit the relay simultaneously. if (!startedConfirmed && desiredCliLinkMode !== "publish_only") { const jitter = yield* Random.nextIntBetween( 0, diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 2191b072b323..fec4db254151 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -132,7 +132,6 @@ const logInSpanThrough = ( return { requests, spans }; }); -/** Logs one warning through the server logger and returns what its console printed. */ const printThroughConsole = (fields: object) => Effect.gen(function* () { const printed: Array = []; @@ -247,7 +246,6 @@ describe("ServerLoggerLive", () => { it.effect("keeps the token out of traces when the browser fails to open", () => Effect.gen(function* () { - // A failed launch records its error, which names the target, on its span. const failingLaunch = maybeOpenBrowser(pairingUrl).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index af7ea3805f20..d13817502979 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -11,12 +11,7 @@ import * as OtlpLogger from "effect/observability/OtlpLogger"; import * as ServerConfig from "./config.ts"; -/** - * `Logger.consolePretty` prints message objects with `util.inspect`, which - * collapses a `Cause` passed as a field (`{ cause }`) to - * `{ failures: [ [Object] ] }`. Render those fields with `Cause.pretty`, the - * same text a `Cause` passed as its own log argument already gets. - */ +// consolePretty collapses Cause fields via util.inspect; render them with Cause.pretty like standalone arguments. const consolePretty = (): Logger.Logger => { const pretty = Logger.consolePretty(); return Logger.make((options) => @@ -35,8 +30,7 @@ function prettyCauseFields(value: unknown): unknown { if (prototype !== Object.prototype && prototype !== null) return value; // A message with its own redaction hook already decides how it prints. if (Redactable.isRedactable(value)) return value; - // Copy descriptors rather than entries so getters are never invoked and - // symbol keys survive the copy. + // Copy descriptors to preserve symbols without invoking getters. const descriptors = Object.getOwnPropertyDescriptors(value); let changed = false; for (const descriptor of Object.values(descriptors)) { @@ -64,13 +58,8 @@ export const layer = Effect.gen(function* () { resource: ServerConfig.otlpResource(config, hostName), }); - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the server wants has to be named in this one call. - // `Logger.tracerLogger` reaches a collector by attaching each message to the - // active span as a span event, which covers only messages logged inside a - // recorded span and files them under traces. The OTLP logger carries the same - // messages as log records stamped with their trace and span ids, so it is a - // superset: keeping both would export every in-span message twice. + // Logger.layer replaces the whole set; name every logger here. + // OTLP includes traced messages as log records, so also using tracerLogger would duplicate in-span exports. const layerLogger = Logger.layer( otlpLogger === undefined ? [consolePretty(), Logger.tracerLogger] diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 50d0b2332c22..195973542551 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -40,8 +40,7 @@ export class ServerAlreadyRunningError extends Schema.TaggedError (("errcode" in cause && cause.errcode === 5) || ("code" in cause && cause.code === "SQLITE_BUSY")); -/** - * Servers from before this lock only publish `server-runtime.json`. Treat - * their record as stale only when the process start time proves PID reuse. - */ +// Pre-lock servers publish only runtime state; require start-time proof of PID reuse before treating it as stale. const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* ( state: PersistedServerRuntimeState, ) { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 18f7468fcb52..43b37a6a056d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -345,19 +345,13 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { : yield* serverAuth.issueStartupPairingUrl(baseTarget); }); -/** - * Prints the startup pairing URL to stdout, where `t3` users and the dev runner - * read it. The URL carries a one-time admin token, so it skips the logger: log - * records also land in the trace file and any configured OTLP collector. - */ +/** Prints the one-time admin pairing URL to stdout only; logger records reach trace files and collectors. */ export const announceStartupPairingUrl = (pairingUrl: string) => Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( - // Indented to match the annotation line the pretty logger printed before. Effect.andThen(Console.log(` pairingUrl: ${pairingUrl}`)), ); -// No URL here: the browser target carries the pairing token, which -// announceStartupPairingUrl has already printed to stdout. +// Pairing tokens belong only in announceStartupPairingUrl's stdout output. export const logBrowserAutoOpenUnavailable = Effect.logInfo("browser auto-open unavailable", { hint: "Open the pairing URL above in your browser.", }); @@ -381,9 +375,7 @@ export const maybeOpenBrowser = Effect.fn("serverRuntimeStartup.maybeOpenBrowser const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( - // Attributes on the span itself, not annotateSpans: an annotation follows - // every fiber forked inside the phase, such as the effect worker, for the - // life of the server. + // Use span attributes; annotations would follow long-lived forked fibers for the server's lifetime. Effect.withSpan(`server.startup.${phase}`, { attributes: { "startup.phase": phase } }), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index abc16c16016b..aca62ebb01ec 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -14,6 +14,7 @@ export const PersistedServerRuntimeState = Schema.Struct({ pid: Schema.Int, ownerId: Schema.optional(Schema.String), host: Schema.optional(Schema.String), + privateNetworkOnly: Schema.optional(Schema.Boolean), port: Schema.Int, origin: Schema.String, // Dev is single-origin: browsers must pair through this URL, not `origin`. @@ -51,7 +52,10 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick< + ServerConfig.ServerConfig["Service"], + "host" | "privateNetworkOnly" | "devUrl" + >; readonly port: number; readonly serviceManaged?: boolean; }): Effect.Effect => @@ -59,6 +63,7 @@ export const makePersistedServerRuntimeState = (input: { version: 1, pid: process.pid, ...(input.config.host ? { host: input.config.host } : {}), + ...(input.config.privateNetworkOnly ? { privateNetworkOnly: true } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), @@ -111,10 +116,7 @@ export const clearPersistedServerRuntimeState = Effect.fn( ); }); -/** - * Signal 0 delivers nothing; it only reports whether the pid exists. EPERM - * means it exists but belongs to another user, which still counts as alive. - */ +/** Signal 0 only probes existence; EPERM still means alive. */ export const isProcessAlive = (pid: number): boolean => { if (!Number.isInteger(pid) || pid <= 0) return false; try { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 61a28c3ad6b3..892b2f68b957 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -697,7 +697,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - // A backend behind a custom model may refuse the built-in default (#12651). + // A backend behind a custom model may refuse the built-in default. it.effect("falls back to the thread default when it is a custom model on that instance", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1341,7 +1341,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.notInclude(yield* fs.readFileString(config.settingsPath), value); assert.notInclude(JSON.stringify(client), value); } - // Editing another field from the client's copy keeps the saved password. const clientConfig = client.providerInstances[opencode]?.config as Record; const edited = yield* service.updateProviderInstance({ operation: "upsert", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 47bbefa509e0..3131c84d880e 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -70,18 +70,13 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); -/** - * Old settings files can hold both flags with conflicting values; an explicit false on either - * side wins so a user's disable is never silently undone. - */ +// Conflicting flags resolve to false to preserve user disables. const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => { let changed = false; const providerInstances: Record = {}; for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { const config = instance.config; - // Only fold boolean flags: a malformed `enabled` (e.g. `"false"`) must - // stay in the blob so driver schema validation flags it instead of the - // fold silently repairing the config. + // Leave malformed flags for driver schema validation rather than silently repairing them. if ( config === null || typeof config !== "object" || @@ -197,10 +192,7 @@ export class ServerSettingsService extends Context.Service< readonly streamChanges: Stream.Stream; - /** - * Use this before reading a snapshot when changes between the snapshot and - * a lazily started stream must not be lost. - */ + /** Subscribe before reading a snapshot to retain changes before lazy stream startup. */ readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { @@ -264,8 +256,7 @@ const makeTest = Effect.fn("serverSettings.makeTest")(function* ( export const layerTest = (overrides: DeepPartial = {}) => Layer.effect(ServerSettingsService, makeTest(overrides)); -// Migrate saved token delivery without accepting it in settings writes or -// letting one retired value reset the rest of the environment's settings. +// Accept retired token delivery only on load so one old value cannot reset all settings. const PersistedResponseStreamingMode = Schema.Union([ ResponseStreamingMode, Schema.Literal("token"), @@ -496,9 +487,7 @@ interface LegacyProjectSettingsRow { readonly scripts: string; } -/** - * Marked with `projectSettingsFolded` so a later reset in the UI survives restarts. - */ +// projectSettingsFolded prevents restarts from undoing later UI resets. function foldLegacyProjectSettings( settings: ServerSettings, rows: ReadonlyArray, @@ -531,8 +520,7 @@ function foldLegacyProjectSettings( for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { set(projectId, "defaultAutoPull", value); } - // A stored null meant "reset to machine defaults", which is now plain - // inheritance; the project's own aggregate scripts must not resurface. + // A stored null resets to machine defaults, so the project's aggregate scripts must not resurface. const resetScripts = new Set(); for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { if (value === null) resetScripts.add(projectId); @@ -755,9 +743,7 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - // Copied, not moved: the old entry is removed only after the new file is written. A token - // that cannot be read or copied leaves the retired keys in place, so the migration runs again - // on the next load instead of dropping it. + // Remove retired keys only after copying secrets and writing the new file; failures retry on next load. const legacySecretsCopied = yield* Effect.forEach( settingsFileTrusted ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves @@ -1091,9 +1077,7 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // A symlinked settings file is rewritten in its destination's directory, - // which a watch on the link's directory never sees. The link is resolved - // again whenever it changes, so repointing it moves the watch along. + // Watch symlink targets' directories for rewrites; re-resolve link changes to follow repointing. const watchLinkTarget = Effect.gen(function* () { const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -1121,8 +1105,7 @@ const make = Effect.gen(function* () { ), ); - // Editors emit multiple events per save (truncate, write, rename) and - // `fs.watch` can fire before the content has been flushed to disk. + // Saves emit multiple events; fs.watch may fire before content is flushed. const debouncedSettingsEvents = Stream.merge( watchFileChanges(settingsPath), linkTargetEvents, diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 9926159be285..9f764b788efe 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,9 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics globalTimers:off -// The launcher supervises the server child for the boot service and must keep -// working across server versions, so it stays on Node built-ins with no Effect -// runtime: it is the one part of the executable that cannot depend on the -// rest of it being loadable. +// The launcher must survive unloadable server versions; use Node built-ins without Effect. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; @@ -39,8 +36,7 @@ const HANDOFF_DELAY_MS = 2_000; */ const PREPARED_TIMEOUT_MS = 30 * 60_000; const TERMINATE_GRACE_MS = 5_000; -// Matches SERVER_EXIT_CODE_STATE_DIR_OWNED in contracts. The standalone -// launcher stays independent of the Effect-backed contracts package. +// Match contracts' SERVER_EXIT_CODE_STATE_DIR_OWNED without depending on Effect. const STATE_DIR_OWNED_EXIT_CODE = 78; type TerminalStatus = "committed" | "rolled-back" | "failed"; @@ -127,9 +123,7 @@ async function syncFile(filePath: string): Promise { } } -// Flushes a directory entry so a rename into it survives power loss. Windows -// has no directory fsync: the handle opens but sync fails with EPERM, and -// NTFS journals the rename on its own. +// Fsync makes renames durable; Windows returns EPERM and NTFS journals them itself. async function syncDirectory(directory: string): Promise { const handle = await NodeFSP.open(directory, "r"); try { @@ -147,14 +141,11 @@ function runCp(args: ReadonlyArray): Promise { }); } -/** - * Node's own clone flag is not used: on macOS `fs.copyFile` falls back to a full copy. - */ +// macOS fs.copyFile clone flags fall back to full copies. async function copyDatabaseFile(source: string, destination: string): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; - // `cp -c` replaces the destination with a new file, which would cut a - // hard-linked database off from its other links; copy into it instead. + // cp -c replaces files and breaks hard links; copy into hard-linked destinations instead. const hardLinked = platform === "darwin" && ((await NodeFSP.stat(destination).catch(() => undefined))?.nlink ?? 1) > 1; @@ -168,10 +159,7 @@ async function copyDatabaseFile(source: string, destination: string): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; @@ -236,10 +219,7 @@ const filesystemBackupSpaceProbe: BackupSpaceProbe = { canClone: backupCanClone, }; -/** - * Runs before the server is stopped so a full disk refuses the update instead of causing an - * outage and a cold restart. - */ +/** Check before stopping the server so full disks refuse updates without an outage. */ export async function databaseBackupBlocker( baseDir: string, dbPath: string, @@ -262,10 +242,7 @@ export async function databaseBackupBlocker( return `Not enough free disk space to back up the database before updating: ${gb(needed)} GB needed, ${gb(free)} GB free. Free up disk space and try again. The server was not stopped.`; } -/** - * A completed backup is never overwritten because a restarted launcher may be looking at - * database writes from an earlier attempt by the same trial. - */ +// Preserve completed backups; restarted trials may already have changed the live database. async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); if (await pathExists(backupDir)) return; @@ -295,7 +272,7 @@ const restoreMarkerPath = (baseDir: string, updateId: string) => const databaseRestorePending = (baseDir: string, pending: PendingServiceUpdate) => pathExists(restoreMarkerPath(baseDir, pending.id)); -/** Mark rollback before changing live files so launcher recovery cannot boot a partial restore. */ +// Mark before live writes so recovery cannot boot a partial restore. async function markDatabaseRestorePending(backupDir: string): Promise { const markerPath = NodePath.join(backupDir, RESTORE_MARKER); if (!(await pathExists(markerPath))) { @@ -309,11 +286,7 @@ async function markDatabaseRestorePending(backupDir: string): Promise { } } -/** - * The file a restore writes for `path`: where its symlinks lead, even when the - * last target is missing, as after a crash between `cp -c` removing a file - * and cloning over it. - */ +// Resolve symlink targets even if a crashed cp -c left the final target missing. async function restoreTarget(path: string): Promise { let current = path; for (let hops = 0; hops < 40; hops++) { @@ -328,8 +301,7 @@ async function restoreTarget(path: string): Promise { throw cause; }); if (link === undefined) return current; - // Joined without normalizing: a `..` in the link must follow directory links - // the way the filesystem does, not cancel a path segment. + // Do not normalize .. before following directory symlinks; match filesystem resolution. current = NodePath.isAbsolute(link) ? link : `${await NodeFSP.realpath(NodePath.dirname(current))}${NodePath.sep}${link}`; @@ -350,8 +322,7 @@ async function restoreDatabaseBackup( const livePath = `${pending.dbPath}${suffix}`; const source = databaseBackupFile(backupDir, suffix); if (await pathExists(source)) { - // A clone replaces its destination, so restore into the file a symlinked - // database resolves to and keep the link itself. + // Restore into the symlink target; cloning would replace the link. const target = await restoreTarget(livePath); await copyDatabaseFile(source, target); await syncFile(target); @@ -526,9 +497,7 @@ export class Launcher { } async stop(signal: NodeJS.Signals): Promise { - // This must happen synchronously at signal receipt. A queued update - // transition may already be terminating the active child, and that child - // needs to see the marker in its shutdown finalizer. + // Write synchronously on signal receipt; a terminating child needs the marker in its finalizer. try { NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); } catch {} @@ -539,9 +508,7 @@ export class Launcher { this.#stopRequested = true; this.#clearTimer(); this.#enqueue(async () => { - // Let an update transition already in progress start its replacement - // before this queued stop tears it down. That replacement owns the - // pre-activation tunnel cleanup path and observes the marker above. + // Let in-progress updates start replacements first; replacements observe the marker and own pre-activation tunnel cleanup. this.#stopping = true; const child = this.#child?.process; this.#child = null; @@ -558,13 +525,7 @@ export class Launcher { } async #recover(): Promise { - // A fresh launcher means servers are running again: any stop marker from - // a previous explicit stop is stale and must not make a future update - // handoff release its tunnel. A restart deferred by `t3 update` is done - // no matter who restarted the service, but only once this launcher is - // the version the marker waits for: a launcher that came up between the - // CLI writing the marker and writing the new state still runs the old - // version, and the marker has to outlive it. + // Clear old stop markers to preserve future tunnels; clear restart-pending only for the awaited version to avoid CLI races. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); const restartPending = restartPendingPath(this.#baseDir); const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); @@ -700,8 +661,7 @@ export class Launcher { await reject("The requested target runtime is missing or incomplete."); return; } - // A check that cannot run refuses the update too: the alternative is - // stopping the server without knowing whether the backup fits. + // Failed checks must refuse updates rather than stop the server with unknown backup capacity. const backupBlocker = await databaseBackupBlocker( this.#baseDir, message.dbPath, @@ -812,11 +772,7 @@ export class Launcher { throw new Error(`Active child exited unexpectedly (${String(code ?? signal ?? "unknown")}).`); } - /** - * Exiting would make systemd or launchd start another refused server, so stay - * idle until stopped or restarted. A pending update fails without restoring - * its backup: the other owner may have written to the database since. - */ + // Stay idle to avoid supervisor restart loops; never restore backups over another owner's possible writes. async #idleWhileStateDirOwned(): Promise { this.#clearTimer(); const pending = this.#state.update; diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index aad475ab54af..c82f5f3cad07 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1,447 +1,131 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { SourceControlProviderKind, VcsProcessSpawnError } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; -import { HttpClient } from "effect/http"; -import { VcsProcessSpawnError } from "@t3tools/contracts"; +import { providerAuth } from "@t3tools/source-control-core/server/discovery"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { + fakeDiscovery, + fakeSourceControlProvider, +} from "@t3tools/source-control-testing/fakeSourceControlDriver"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; -import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; -const layerSourceControlProviderRegistryTest = (input: { - readonly bitbucket: Partial; - readonly process: Partial; -}) => - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-registry-test-", - }).pipe(Layer.provide(NodeServices.layer)), - Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), - Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), - Layer.mock(GitHubRest.GitHubRest)({}), - // The GitHub provider's GraphQL reads go through these; no test here makes one. - Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), - ), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(GitLabCli.GitLabCli)({}), - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), - Layer.mock(GitCafeApi.GitCafeApi)({}), - Layer.mock(GitCafeCredentials.GitCafeCredentials)({ - cliEnv: () => Effect.succeed({}), - }), - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), - Layer.mock(VcsProcess.VcsProcess)(input.process), - ServerSourceControlHost.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(VcsProcess.VcsProcess)(input.process), - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), - ), - ), - ), - ), - ), +const kind = SourceControlProviderKind.make; + +// Serves both the host's runs and the server's own VCS probes, whose inputs share these fields. +const run = ( + input: Pick< + SourceControlHost.SourceControlProcessInput, + "operation" | "command" | "args" | "cwd" + >, +): Effect.Effect => { + if (input.args[0] === "--version") { + return input.command === "missing-cli" + ? Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ) + : Effect.succeed(TestSourceControlHost.processOutput(`${input.command} version test\n`)); + } + return Effect.succeed( + TestSourceControlHost.processOutput(`signed in as ${input.command}-user\n`), ); +}; -const processOutput = ( - stdout: string, - options?: { - readonly stderr?: string; - readonly exitCode?: ChildProcessSpawner.ExitCode; - }, -): VcsProcess.VcsProcessOutput => ({ - exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), - stdout, - stderr: options?.stderr ?? "", - stdoutTruncated: false, - stderrTruncated: false, +const cliDiscovery = (name: string) => ({ + type: "cli" as const, + kind: kind(name), + label: name, + installHint: `Install ${name}.`, + executable: name === "beta" ? "missing-cli" : `${name}-cli`, + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: ({ stdout }: { readonly stdout: string }) => + providerAuth({ status: "authenticated", account: /as (\S+)/.exec(stdout)?.[1] }), }); -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -it.effect("probes provider authentication without exposing token details", () => { - const processMock = { - run: (input: VcsProcess.VcsProcessInput) => { - if (input.args[0] === "--version") { - return Effect.succeed(processOutput(`${input.command} version test\n`)); - } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { - return Effect.succeed( - processOutput( - encodeJson({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "octocat", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), - ); - } - if (input.command === "glab" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`gitlab.com -Logged in to gitlab.com as gitlab-user -`), - ); - } - if (input.command === "tea" && input.args[0] === "login") { - return Effect.succeed( - processOutput( - encodeJson([ - { - name: "forgejo", - url: "https://forgejo.example.com", - ssh_host: "forgejo.example.com", - user: "forgejo-user", - valid: "true", - default: "true", - }, - ]), - ), - ); - } - if ( - input.command === "az" && - input.args.join(" ") === "account show --query user.name -o tsv" - ) { - return Effect.succeed(processOutput("azure-user@example.com\n")); - } - if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") { - return Effect.succeed( - processOutput( - encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }), - ), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - } satisfies Partial; - const layerTest = SourceControlDiscovery.layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-auth-discovery-", - }), - ), - Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), - Layer.provide( - layerSourceControlProviderRegistryTest({ - process: processMock, - bitbucket: { - probeAuth: Effect.succeed({ - status: "authenticated", - account: Option.some("bitbucket-user"), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }), - }, +const layerDiscovery = Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + SourceControlProviderRegistry.makeWithProviders( + [ + cliDiscovery("alpha"), + cliDiscovery("beta"), + fakeDiscovery("gamma", { + probeAuth: Effect.succeed(providerAuth({ status: "authenticated", account: "api-user" })), }), - ), - Layer.provideMerge(NodeServices.layer), - ); + ].map((discovery) => ({ + kind: discovery.kind, + provider: fakeSourceControlProvider(discovery.kind), + discovery, + })), + ), +).pipe( + Layer.provideMerge(TestSourceControlHost.layer({ process: { run } })), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), + Layer.provideMerge(Layer.mock(VcsProcess.VcsProcess)({ run })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ), + Layer.provideMerge(NodeServices.layer), +); - return Effect.gen(function* () { +it.effect("reports every hosting integration in registry order with its sign-in", () => + Effect.gen(function* () { const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; const result = yield* discovery.discover; assert.deepStrictEqual( result.sourceControlProviders.map((item) => ({ kind: item.kind, + status: item.status, + version: item.version, auth: item.auth.status, account: item.auth.account, - detail: item.auth.detail, })), [ { - kind: SourceControlProviderKind.make("github"), + kind: kind("alpha"), + status: "available", + version: Option.some("alpha-cli version test"), auth: "authenticated", - account: Option.some("octocat"), - detail: Option.none(), + account: Option.some("alpha-cli-user"), }, { - kind: SourceControlProviderKind.make("gitlab"), - auth: "authenticated", - account: Option.some("gitlab-user"), - detail: Option.none(), + kind: kind("beta"), + status: "missing", + version: Option.none(), + auth: "unknown", + account: Option.none(), }, { - kind: SourceControlProviderKind.make("azure-devops"), + kind: kind("gamma"), + status: "available", + version: Option.none(), auth: "authenticated", - account: Option.some("azure-user@example.com"), - detail: Option.none(), - }, - { - kind: SourceControlProviderKind.make("bitbucket"), - auth: "authenticated", - account: Option.some("bitbucket-user"), - detail: Option.none(), - }, - { - kind: SourceControlProviderKind.make("forgejo"), - auth: "authenticated", - account: Option.some("forgejo-user"), - detail: Option.none(), - }, - { - kind: SourceControlProviderKind.make("gitcafe"), - auth: "authenticated", - account: Option.some("cafe-user"), - detail: Option.none(), + account: Option.some("api-user"), }, ], ); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect( - "checks out fj pull refs and preserves existing branches and dirty files until forced", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const git = yield* VcsProcess.VcsProcess; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fj-checkout-" }); - const source = path.join(root, "source"); - const cwd = path.join(root, "checkout"); - yield* fs.makeDirectory(source); - for (const args of [ - ["init", "-b", "main"], - ["config", "user.name", "Test"], - ["config", "user.email", "test@example.com"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); - yield* fs.writeFileString(path.join(source, "base.txt"), "base\n"); - for (const args of [ - ["add", "base.txt"], - ["commit", "-m", "base"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); - const base = (yield* git.run({ - operation: "test.setup", - command: "git", - cwd: source, - args: ["rev-parse", "HEAD"], - })).stdout.trim(); - yield* git.run({ - operation: "test.setup", - command: "git", - cwd: root, - args: ["clone", source, cwd], - }); - yield* fs.writeFileString(path.join(source, "feature.txt"), "pull request change\n"); - for (const args of [ - ["add", "feature.txt"], - ["commit", "-m", "feature"], - ["update-ref", "refs/pull/42/head", "HEAD"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); - const head = (yield* git.run({ - operation: "test.setup", - command: "git", - cwd: source, - args: ["rev-parse", "HEAD"], - })).stdout.trim(); - const fetched: string[] = []; - const host = yield* SourceControlHost.SourceControlHost; - const provider = yield* ForgejoSourceControlProvider.make.pipe( - Effect.provideService( - SourceControlHost.SourceControlHost, - SourceControlHost.SourceControlHost.of({ - settings: host.settings, - git: host.git, - process: { - run: (input) => { - if (input.args[0] !== "fetch") return git.run(input); - const url = input.args[2]; - assert.isDefined(url); - fetched.push(url!); - return git.run({ - ...input, - args: input.args.map((arg) => - arg === "git@forgejo.test:reviewer/project.git" ? source : arg, - ), - }); - }, - }, - }), - ), - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - resolveRepository: () => - Effect.succeed({ - command: "fj", - login: "work", - repository: "reviewer/project", - baseUrl: "https://forgejo.test", - }), - api: (input) => { - assert.include( - ["repos/reviewer/project", "repos/reviewer/project/pulls/42"], - input.path, - ); - return Effect.succeed( - processOutput( - encodeJson( - input.path.endsWith("/pulls/42") - ? { - number: 42, - title: "Checkout", - html_url: "https://forgejo.test/reviewer/project/pulls/42", - state: "open", - merged: false, - base: { ref: "main", sha: base, repo: null }, - head: { ref: "feature", sha: head, repo: null }, - } - : { - full_name: "reviewer/project", - clone_url: source, - ssh_url: "git@forgejo.test:reviewer/project.git", - default_branch: "main", - }, - ), - ), - ); - }, - }), - ), - ); - yield* provider.checkoutChangeRequest({ - cwd, - reference: "https://forgejo.test/reviewer/project/pulls/42", - }); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["branch", "--show-current"], - })).stdout.trim(), - "pulls/42", - ); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["rev-parse", "HEAD"], - })).stdout.trim(), - head, - ); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "feature.txt")), - "pull request change\n", - ); - for (const args of [ - ["checkout", "main"], - ["branch", "-f", "pulls/42", "main"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd, args }); - yield* fs.writeFileString(path.join(cwd, "base.txt"), "uncommitted work\n"); - yield* provider.checkoutChangeRequest({ cwd, reference: "42" }); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["rev-parse", "HEAD"], - })).stdout.trim(), - base, - ); - assert.strictEqual(yield* fs.exists(path.join(cwd, "feature.txt")), false); - yield* provider.checkoutChangeRequest({ - cwd, - reference: "42", - force: true, - context: { - provider: { - kind: SourceControlProviderKind.make("forgejo"), - name: "Forgejo", - baseUrl: "https://forgejo.test", - }, - remoteName: "origin", - remoteUrl: "git@forgejo.test:maria/project.git", - }, - }); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["rev-parse", "HEAD"], - })).stdout.trim(), - head, - ); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "base.txt")), - "uncommitted work\n", - ); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "feature.txt")), - "pull request change\n", - ); - assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]); - }).pipe( - Effect.scoped, - Effect.provide( - ServerSourceControlHost.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), - Layer.provideMerge(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), - Layer.provideMerge(NodeServices.layer), - ), - ), - ), + assert.deepStrictEqual( + result.versionControlSystems.map((item) => [item.kind, item.status]), + [ + ["git", "available"], + ["jj", "available"], + ], + ); + }).pipe(Effect.provide(SourceControlDiscovery.layer.pipe(Layer.provide(layerDiscovery)))), ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index f56985fefd63..b6c8239f1705 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -64,7 +64,7 @@ export class SourceControlDiscovery extends Context.Service< } >()("t3/sourceControl/SourceControlDiscovery") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 80738fcf38a2..249bc07b3ad3 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -9,53 +9,78 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as PubSub from "effect/PubSub"; -import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient } from "effect/http"; -import { ChildProcessSpawner } from "effect/process"; import { DEFAULT_SERVER_SETTINGS, VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; +import type { + SourceControlCliDiscoverySpec, + SourceControlManagedCliDiscoverySpec, + SourceControlProviderDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { fakeSourceControlProvider } from "@t3tools/source-control-testing/fakeSourceControlDriver"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const kind = SourceControlProviderKind.make; -const processOutput = ( - stdout: string, - options?: { - readonly stderr?: string; - readonly exitCode?: ChildProcessSpawner.ExitCode; - }, -): VcsProcess.VcsProcessOutput => ({ - exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), - stdout, - stderr: options?.stderr ?? "", - stdoutTruncated: false, - stderrTruncated: false, +class NotAnswered extends Schema.TaggedError()("NotAnswered", {}) {} + +const linkSubject = (host: string) => () => + Effect.succeed({ title: `${host} subject`, body: null }); + +/** Only GitHub and GitLab read linked subjects, as in production. */ +const PROVIDERS = [ + fakeSourceControlProvider("github", { resolveLink: linkSubject("github") }), + fakeSourceControlProvider("gitlab", { resolveLink: linkSubject("gitlab") }), + fakeSourceControlProvider("forgejo"), + fakeSourceControlProvider("bitbucket"), + fakeSourceControlProvider("azure-devops"), +]; + +/** A host CLI that claims every server it is signed in to, as `glab` does. */ +const claimingCli = (claims: string): SourceControlCliDiscoverySpec => ({ + type: "cli", + kind: kind(claims), + label: claims, + installHint: `Install ${claims}.`, + executable: `${claims}-cli`, + versionArgs: ["--version"], + authArgs: ["auth", "status"], + remoteRefinementArgs: ({ provider }) => ["auth", "status", "--hostname", provider.name], + parseAuth: () => ({ + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), + refineUnknownRemote: ({ context, auth }) => + auth.stdout.includes(`Logged in to ${context.provider.name}`) + ? { kind: kind(claims), name: `${claims} Self-Hosted`, baseUrl: context.provider.baseUrl } + : null, +}); + +const managedCli = ( + claims: string, + refine: SourceControlManagedCliDiscoverySpec["refineUnknownRemote"], +): SourceControlManagedCliDiscoverySpec => ({ + type: "managed-cli", + kind: kind(claims), + label: claims, + installHint: `Install ${claims}.`, + probe: () => Effect.die("unused"), + refineUnknownRemote: refine, }); function makeRegistry(input: { @@ -63,12 +88,9 @@ function makeRegistry(input: { readonly name: string; readonly url: string; }>; - readonly process?: Partial; - readonly githubApi?: Partial; - readonly gitlab?: Partial; - readonly forgejo?: Partial; + readonly discovery?: ReadonlyArray; + readonly run?: SourceControlHost.SourceControlHost["Service"]["process"]["run"]; readonly settings?: Partial; - readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; }) { const driver = { listRemotes: () => @@ -86,78 +108,47 @@ function makeRegistry(input: { }), } satisfies Partial; - const layerRegistry = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), - resolve: - input.resolve ?? - (() => - Effect.succeed({ - kind: "git", - repository: { - kind: "git", - rootPath: "/repo", - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - }, - driver: driver as unknown as VcsDriver.VcsDriver["Service"], - })), - }); - - const layerProcess = Layer.mock(VcsProcess.VcsProcess)({ - run: () => Effect.succeed(processOutput("")), - ...input.process, - }); - - return SourceControlProviderRegistry.make.pipe( + const discovery = input.discovery ?? [claimingCli("gitlab")]; + return SourceControlProviderRegistry.makeWithProviders( + PROVIDERS.map((provider) => ({ + kind: provider.kind, + provider, + discovery: discovery.find((spec) => spec.kind === provider.kind) ?? { + type: "api", + kind: provider.kind, + label: provider.kind, + installHint: "unused", + probeAuth: Effect.die("unused"), + }, + })), + ).pipe( Effect.provide( Layer.mergeAll( - NodeServices.layer, - layerRegistry, - layerProcess, - ServerSourceControlHost.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerProcess, - layerRegistry, - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - ), - ), - ), - Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), - Layer.mock(BitbucketApi.BitbucketApi)({}), - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], + }), + }), + TestSourceControlHost.layer({ + process: { + run: input.run ?? (() => Effect.succeed(TestSourceControlHost.processOutput(""))), + }, + }), input.settings ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings) : ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), - // The GitHub provider's GraphQL reads go through these; no test here makes one. - Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), - ), - Layer.mock(GitHubRest.GitHubRest)({ - // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. - credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), - ...input.githubApi, - }), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ - listLogins: () => Effect.succeed([]), - ...input.forgejo, - }), - Layer.mock(GitCafeApi.GitCafeApi)({}), - Layer.mock(GitCafeCredentials.GitCafeCredentials)({ - cliEnv: () => Effect.succeed({}), - }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), @@ -166,30 +157,40 @@ function makeRegistry(input: { ); } -it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => +const signedIn = (host: string) => () => + Effect.succeed(TestSourceControlHost.processOutput(` ✓ Logged in to ${host} as user\n`)); + +it.effect("routes an unknown remote to the integration that claims its server", () => Effect.gen(function* () { + const asked: Array = []; const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }], - process: { - run: () => - Effect.succeed( - processOutput( - `gitlab.com - x gitlab.com: API call failed: 401 Unauthorized - ! No token found -self-hosted.example.test - ✓ Logged in to self-hosted.example.test as gitlab-user - ✓ Token found: ****** -`, - { exitCode: ChildProcessSpawner.ExitCode(1) }, - ), - ), + remotes: [{ name: "origin", url: "git@self-hosted.example.test:group/project.git" }], + run: (input) => { + asked.push([input.command, ...input.args].join(" ")); + return signedIn("self-hosted.example.test")(); }, }); - const provider = yield* registry.resolve({ cwd: "/repo" }); + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + + assert.strictEqual(handle.provider.kind, "gitlab"); + assert.deepStrictEqual(handle.context?.provider, { + kind: kind("gitlab"), + name: "gitlab Self-Hosted", + baseUrl: "https://self-hosted.example.test", + }); + assert.deepStrictEqual(asked, ["gitlab-cli auth status --hostname self-hosted.example.test"]); + }), +); - assert.strictEqual(provider.kind, "gitlab"); +it.effect("leaves a remote no integration claims unknown, with its server's address", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }], + }); + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + assert.strictEqual(handle.provider.kind, kind("unknown")); + assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); }), ); @@ -197,21 +198,14 @@ it.effect("refines the caller-selected remote instead of choosing another config Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }], - process: { - run: () => - Effect.succeed( - processOutput(`self-hosted.example.test - ✓ Logged in to self-hosted.example.test as gitlab-user -`), - ), - }, + run: signedIn("self-hosted.example.test"), }); const handle = yield* registry.resolveHandle({ cwd: "/repo", context: { provider: { - kind: SourceControlProviderKind.make("unknown"), + kind: kind("unknown"), name: "self-hosted.example.test", baseUrl: "https://self-hosted.example.test", }, @@ -225,29 +219,6 @@ it.effect("refines the caller-selected remote instead of choosing another config }), ); -it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://self-hosted.example.test:8443/group/project.git" }], - process: { - run: () => - Effect.succeed( - processOutput( - `self-hosted.example.test:8443 - ✓ Logged in to self-hosted.example.test:8443 as gitlab-user - ✓ Token found: ****** -`, - ), - ), - }, - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "gitlab"); - }), -); - it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ @@ -261,44 +232,22 @@ it.effect("falls back to a non-origin remote when origin is not configured", () ); it.effect( - "routes linked subjects by URL independently of the checkout and skips unsupported links", + "routes linked subjects to the host that serves them, independently of the checkout", () => Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }], - githubApi: { - rest: () => - Effect.succeed({ - status: 200, - headers: {}, - body: JSON.stringify({ title: "GitHub issue", body: null }), - truncated: false, - invalidUtf8: false, - }), - }, - gitlab: { - execute: () => - Effect.succeed( - processOutput(JSON.stringify({ title: "GitLab MR", description: "Nested project" })), - ), - }, }); - for (const [url, expected] of [ - ["https://github.com/team/project/issues/1", { title: "GitHub issue", body: null }], - [ - "https://gitlab.com/team/sub/project/-/merge_requests/2", - { title: "GitLab MR", body: "Nested project" }, - ], + for (const [url, host] of [ + ["https://github.com/team/project/issues/1", "github"], + ["https://gitlab.com/team/sub/project/-/merge_requests/2", "gitlab"], ] as const) { const lookup = registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }); assert.ok(lookup); - assert.deepStrictEqual(yield* lookup, expected); + assert.deepStrictEqual(yield* lookup, { title: `${host} subject`, body: null }); } for (const url of [ "https://example.test/team/project/issues/1", - "https://github.attacker.test/team/project/issues/1", - "https://gitlab.attacker.test/team/project/-/issues/1", - "https://github.com/team/project", "https://codeberg.org/team/project/issues/1", "https://bitbucket.org/team/project/pull-requests/1", "https://dev.azure.com/org/project/_git/repo/pullrequest/1", @@ -308,70 +257,38 @@ it.effect( assert.strictEqual( registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }), undefined, + url, ); } }).pipe(Effect.scoped), ); -it.effect.each([ - { name: "a credential", credential: "found", expected: "github" }, - { name: "a host turned off in Settings", credential: "disabled", expected: "github" }, - { name: "no credential", credential: "missing", expected: "unknown" }, -] as const)("resolves custom-host remotes with $name", (scenario) => - Effect.gen(function* () { - const hosts: Array = []; - const commands: Array = []; - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }], - process: { - run: ({ command }) => { - commands.push(command); - return Effect.succeed(processOutput("")); - }, - }, - githubApi: { - credential: (host) => { - hosts.push(host); - return scenario.credential === "found" - ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` }) - : scenario.credential === "disabled" - ? Effect.fail(new GitHubCredentials.GitHubHostDisabledError({ host })) - : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })); - }, - }, - }); - const handle = yield* registry.resolveHandle({ cwd: "/repo" }); - assert.strictEqual(handle.provider.kind, SourceControlProviderKind.make(scenario.expected)); - assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); - assert.deepStrictEqual(hosts, ["code.example.test"]); - assert.notInclude(commands, "gh"); - }), -); - -it.effect("skips GitHub discovery for the identity resolver's empty base URL", () => +it.effect("does not refine a context whose server address is empty", () => Effect.gen(function* () { - const hosts: string[] = []; + let asked = 0; const remoteUrl = "git@githubenterprise.dev.example.com:team/workspace.git"; const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: remoteUrl }], - githubApi: { - credential: (host) => { - hosts.push(host); - return Effect.succeed({ - token: Redacted.make("token"), - fingerprint: `${host}:fingerprint`, - }); - }, - }, + discovery: [ + managedCli("github", ({ context }) => { + asked += 1; + return Effect.succeed( + context.provider.baseUrl === "" + ? null + : { kind: kind("github"), name: "GitHub", baseUrl: context.provider.baseUrl }, + ); + }), + ], }); const context = { - provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, + provider: { kind: kind("unknown"), name: "Unknown", baseUrl: "" }, remoteName: "origin", remoteUrl, }; - const unresolved = yield* registry.resolveHandle({ cwd: "/repo", context }); - assert.deepStrictEqual(unresolved.context, context); - assert.deepStrictEqual(hosts, []); + assert.deepStrictEqual( + (yield* registry.resolveHandle({ cwd: "/repo", context })).context, + context, + ); const resolved = yield* registry.resolveHandle({ cwd: "/repo" }); assert.strictEqual(resolved.provider.kind, "github"); @@ -379,7 +296,7 @@ it.effect("skips GitHub discovery for the identity resolver's empty base URL", ( resolved.context?.provider.baseUrl, "https://githubenterprise.dev.example.com", ); - assert.deepStrictEqual(hosts, ["githubenterprise.dev.example.com"]); + assert.strictEqual(asked, 2); }), ); @@ -399,7 +316,7 @@ const resolveUnknown = ( cwd: `/${repository}`, context: { provider: { - kind: SourceControlProviderKind.make("unknown"), + kind: kind("unknown"), name: "git.example.internal", baseUrl: "https://git.example.internal", }, @@ -414,24 +331,16 @@ it.effect("asks once for every checkout of a server no integration claims", () = const release = yield* Deferred.make(); const registry = yield* makeRegistry({ remotes: [], - process: { - run: ({ command, args, cwd }) => { - asked.push([command, ...args].join(" ")); - return Deferred.await(release).pipe(Effect.andThen(Effect.fail(notFound(command, cwd)))); - }, - }, - githubApi: { - credential: (host) => { - asked.push(`credential ${host}`); - return Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })); - }, - }, - forgejo: { - listLogins: ({ command = "tea", cwd }) => { - asked.push(command); - const cause = notFound(command, cwd); - return Effect.fail(new ForgejoCli.ForgejoCliError({ command, cwd, detail: "", cause })); - }, + discovery: [ + claimingCli("gitlab"), + managedCli("github", ({ context }) => { + asked.push(`managed ${context.provider.name}`); + return Effect.succeed(null); + }), + ], + run: ({ command, args, cwd }) => { + asked.push([command, ...args].join(" ")); + return Deferred.await(release).pipe(Effect.andThen(Effect.fail(notFound(command, cwd)))); }, }); @@ -454,10 +363,8 @@ it.effect("asks once for every checkout of a server no integration claims", () = ); const once = asked.slice(); assert.sameMembers(once, [ - "credential git.example.internal", - "glab auth status --hostname Git.Example.Internal", - "fj", - "tea", + "managed git.example.internal", + "gitlab-cli auth status --hostname git.example.internal", ]); yield* TestClock.adjust("5 minutes"); @@ -469,20 +376,27 @@ it.effect("asks once for every checkout of a server no integration claims", () = it.effect("asks again after a check that could not answer", () => Effect.gen(function* () { let asks = 0; + let managedAsks = 0; const registry = yield* makeRegistry({ remotes: [], - process: { - run: ({ command, cwd }) => { - asks += 1; - return Effect.fail( - new VcsProcessTimeoutError({ operation: "test", command, cwd, timeoutMs: 1 }), - ); - }, + discovery: [ + claimingCli("gitlab"), + managedCli("github", () => { + managedAsks += 1; + return Effect.fail(new NotAnswered()); + }), + ], + run: ({ command, cwd }) => { + asks += 1; + return Effect.fail( + new VcsProcessTimeoutError({ operation: "test", command, cwd, timeoutMs: 1 }), + ); }, }); yield* resolveUnknown(registry, "a"); yield* resolveUnknown(registry, "b"); assert.strictEqual(asks, 2); + assert.strictEqual(managedAsks, 2); }), ); @@ -490,7 +404,7 @@ it.effect("asks again once Settings change", () => Effect.gen(function* () { const changes = yield* PubSub.unbounded(); const handled = yield* Deferred.make(); - let signedIn = false; + let claims = false; const registry = yield* makeRegistry({ remotes: [], settings: { @@ -507,16 +421,18 @@ it.effect("asks again once Settings change", () => ), ), }, - process: { run: ({ command, cwd }) => Effect.fail(notFound(command, cwd)) }, - githubApi: { - credential: (host) => - signedIn - ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` }) - : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), - }, + discovery: [ + managedCli("github", ({ context }) => + Effect.succeed( + claims + ? { kind: kind("github"), name: "GitHub", baseUrl: context.provider.baseUrl } + : null, + ), + ), + ], }); assert.strictEqual((yield* resolveUnknown(registry, "a")).provider.kind, "unknown"); - signedIn = true; + claims = true; assert.strictEqual((yield* resolveUnknown(registry, "b")).provider.kind, "unknown"); yield* PubSub.publish(changes, DEFAULT_SERVER_SETTINGS); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 6109fe43c19b..ad83942f2afd 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -13,9 +13,9 @@ import { type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; import type {} from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as BuiltInDrivers from "./builtInDrivers.ts"; +import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { probeSourceControlProvider, @@ -74,9 +74,7 @@ function unsupportedProvider( ): SourceControlProvider.SourceControlProvider["Service"] { return SourceControlProvider.SourceControlProvider.of({ kind, - // A remote on a host no provider recognizes (or no remote at all) has no change - // requests T3 could know about. Answering with none keeps the status poller from - // failing, and warning, on every refresh of such a repository. + // Unknown hosts have no readable change requests; return none to avoid poll warnings on every refresh. listChangeRequests: (input) => kind === "unknown" ? Effect.succeed([]) @@ -144,7 +142,7 @@ function selectProviderContext( ): SourceControlProvider.SourceControlProviderContext | null { const candidates: Array = []; for (const remote of remotes) { - const provider = detectSourceControlProviderFromRemoteUrl(remote.url); + const provider = sourceControlDefinitions.recognizeRemote(remote.url); if (provider) { candidates.push({ provider, @@ -162,10 +160,7 @@ function selectProviderContext( ); } -/** - * `cwd` is left out because it only matters when a probe fails there, and a failed probe - * is not kept. Equality ignores `lookup`, so this is not a `Data.Class`. - */ +// Failed probes are not cached, so cwd is irrelevant; equality ignores lookup, unlike Data.Class. class UnknownRemoteLookup { readonly key: string; readonly lookup: Effect.Effect; @@ -235,7 +230,7 @@ function bindProviderContext( }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig.ServerConfig; @@ -337,7 +332,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit if (input.url.protocol !== "https:" || input.url.username || input.url.password) { return undefined; } - const kind = detectSourceControlProviderFromRemoteUrl(input.url.href)?.kind; + const kind = sourceControlDefinitions.recognizeRemote(input.url.href)?.kind; return kind ? providers.get(kind)?.resolveLink?.(input) : undefined; }, get, @@ -357,17 +352,15 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }, ); -export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe( - Effect.map((instance): SourceControlProviderRegistration => ({ - kind: driver.kind, - provider: instance.sourceControl, - discovery: instance.discovery, - })), - ), +const make = Effect.gen(function* () { + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return yield* makeWithProviders( + drivers.map(({ driver, instance }): SourceControlProviderRegistration => ({ + kind: driver.kind, + provider: instance.sourceControl, + discovery: instance.discovery, + })), ); - return yield* makeWithProviders(drivers); }); export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 14615a39856d..4a9105e9f5d9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -101,6 +101,51 @@ function layer(input: { : layerService.pipe(Layer.provideMerge(NodeServices.layer)); } +const lookupFailingWith = (cause: unknown, detail: string) => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service + .lookupRepository({ provider: SourceControlProviderKind.make("bitbucket"), repository: "x" }) + .pipe(Effect.flip); + }).pipe( + Effect.provide( + layer({ + provider: makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.fail( + new SourceControlProviderError({ + provider: SourceControlProviderKind.make("bitbucket"), + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + detail, + cause, + }), + ), + }), + }), + ), + ); + +it.effect("tells the user a host's fixed explanation of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + { userFacingDetail: "Repositories are named workspace/repository." }, + "Repositories are named workspace/repository.", + ); + assert.strictEqual(error.detail, "Repositories are named workspace/repository."); + }), +); + +it.effect("keeps a host's raw response text out of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + new Error("upstream"), + "internal token=abc", + ); + assert.strictEqual(error.detail, "The source control operation could not be completed."); + }), +); + it.effect("clones a looked-up repository after creating missing parent directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index cba0df9d9056..e89a6330a69e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -30,14 +30,13 @@ import { type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { cloneUrlForProtocol, cloneUrlProtocol } from "@t3tools/shared/cloneUrl"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); const isGitCommandError = Schema.is(GitCommandError); -const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -74,14 +73,10 @@ export interface SourceControlCloneOptions { readonly timeoutMs?: number | null; } -// The synchronous RPC (older clients, mobile) keeps a deadline: nothing else -// tells the user a clone stalled. The tracked path passes null and relies on -// progress and Cancel instead. +// Older/mobile synchronous RPCs need deadlines; tracked clones use progress and Cancel instead. const CLONE_TIMEOUT_MS = 120_000; const CLONE_ENV = { - // `--progress` forces the transfer counters through the pipe; the delay env - // makes the checkout counter start immediately. No tty means a credential - // prompt would hang forever, so tell git to fail instead. + // Force piped progress immediately; refuse credential prompts that would hang without a tty. GIT_PROGRESS_DELAY: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", @@ -95,11 +90,9 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi operation, provider, detail: - isSourceControlProviderError(cause) && - cause.provider === "bitbucket" && - isBitbucketRepositoryLocatorError(cause.cause) - ? BitbucketApi.BitbucketRepositoryLocatorError.detail - : "The source control operation could not be completed.", + (isSourceControlProviderError(cause) + ? SourceControlProvider.userFacingSourceControlDetail(cause.cause) + : undefined) ?? "The source control operation could not be completed.", cause, }), ); @@ -117,11 +110,7 @@ function toRepositoryInfo( }; } -/** - * The URL clients see. A pasted `https://user:token@host/…` must not travel - * back over `subscribeProjectClones` to every reader; git still gets the - * original. - */ +// Redact credentials from client clone snapshots; git still receives the original URL. function redactRemoteUrl(remoteUrl: string): string { try { const url = new URL(remoteUrl); @@ -138,20 +127,15 @@ function redactRemoteUrl(remoteUrl: string): string { } } -// Userinfo may itself contain `@`; everything up to the last one before the -// host boundary goes. +// Userinfo can contain @; remove through the last one before the host boundary. const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; function redactUrlCredentials(text: string): string { return text.replace(URL_WITH_USERINFO, "$1"); } -/** - * Explains a failed first push of a published repository. The repository - * already exists by then, so publishing again cannot help; the user has to - * fix access and push. Built from fixed text and the provider's URLs only, - * since git's stderr can carry credentials. - */ +// The remote already exists after push failure; advise fixing access and pushing. +// Use fixed text and provider URLs because git stderr can expose credentials. function publishPushFailureDetail(input: { readonly urls: SourceControlRepositoryCloneUrls; readonly remoteName: string; @@ -174,12 +158,8 @@ function publishPushFailureDetail(input: { } /** - * Adding a project clones over SSH for most hosts, and a machine with no SSH - * key there would fail every time, while the host's HTTPS credential helper - * may be set up. A clone whose SSH login was refused is retried once over - * HTTPS, unless the caller asked for SSH. A looked-up repository has the - * provider's own HTTPS URL; any other URL is rewritten, which is refused - * where it would have to guess. + * SSH rejection can be retried over HTTPS when the host's credential helper is available. + * Explicit SSH requests must not fall back; unknown URL shapes must not be guessed. */ function httpsFallbackUrl( input: SourceControlCloneRepositoryInput, @@ -207,7 +187,7 @@ function selectRemoteUrl( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -347,9 +327,7 @@ export const make = Effect.gen(function* () { options?: SourceControlCloneOptions, ) { const onProgress = options?.onProgress; - // Git interleaves progress redraws with its real messages on stderr. The - // last non-progress lines are what explain a failure ("Repository not - // found", "Permission denied"), so keep them for the error detail. + // Git mixes progress and diagnostics on stderr; retain non-progress tail lines for failure details. const stderrTail: Array = []; const onStderrLine = (line: string) => { const parsed = parseGitCloneProgressLine(line); @@ -368,9 +346,7 @@ export const make = Effect.gen(function* () { cwd: path.dirname(destinationPath), args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, - // Progress redraws add up on a slow multi-GB clone. The buffered copy - // is never read (the tail is kept by hand above), so keep it small - // and let the line callbacks keep flowing past the cap. + // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, appendTruncationMarker: true, keepLineCallbacksAfterTruncation: true, @@ -435,11 +411,8 @@ export const make = Effect.gen(function* () { destinationPath: string, ) { const normalized = yield* normalizeDestinationPath(destinationPath); - // Only what git left behind may go. The destination was empty when the - // clone started, so anything without a `.git` inside was put there by - // someone else since; refuse rather than delete their files. - // A missing destination is already discarded; any other read failure - // (a file in its place, permissions) is not something to remove through. + // Only .git-bearing partial clones may be deleted; other files arrived after the empty-directory check. + // Missing destinations are already discarded; other read failures must refuse removal. const entries = yield* fileSystem.readDirectory(normalized).pipe( Effect.catchIf( (cause) => cause.reason._tag === "NotFound", @@ -462,9 +435,7 @@ export const make = Effect.gen(function* () { detail: "Destination path contains files that are not from the clone.", }); } - // The directory itself is the project's workspace root and must stay; - // only git's partial contents go. An interrupted git may still be closing - // files, so removal retries briefly. + // Preserve the workspace directory after removing partial contents; retry while interrupted git closes files. yield* fileSystem.remove(normalized, { recursive: true, force: true }).pipe( Effect.andThen(fileSystem.makeDirectory(normalized, { recursive: true })), Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 5 }), @@ -499,10 +470,7 @@ export const make = Effect.gen(function* () { url: remoteUrl, }); - // An empty local repo (no commits) would make `git push HEAD:...` fail - // with an opaque "src refspec HEAD does not match any". Treat this as a - // partial success: the remote was created and wired up, but there is - // nothing to push yet. + // Empty repos cannot push HEAD; report partial success because the remote already exists. const hasCommits = yield* git .execute({ operation: "SourceControlRepositoryService.publishRepository.headCheck", diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 9af54853a049..0e994dcac4a7 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -1,51 +1,31 @@ -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; -import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import type { + SourceControlDriver, + SourceControlDriverInstance, +} from "@t3tools/source-control-core/server/driver"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControlDefinitions.ts"; -const drivers = [ - GitHubDriver.driver, - GitLabDriver.driver, - AzureDevOpsDriver.driver, - BitbucketDriver.driver, - ForgejoDriver.driver, - GitCafeDriver.driver, -]; +export interface SourceControlDriverEntry { + readonly driver: Pick; + readonly instance: SourceControlDriverInstance; +} -export type BuiltInSourceControlDriversEnv = - (typeof drivers)[number] extends SourceControlDriver ? R : never; +/** Every driver's instance, in registry order. Everything that reads a host reads it here. */ +export class SourceControlDrivers extends Context.Service< + SourceControlDrivers, + ReadonlyArray +>()("t3/sourceControl/builtInDrivers/SourceControlDrivers") {} -export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< - SourceControlDriver -> = drivers; +const make = Effect.forEach(BUILT_IN_SOURCE_CONTROL_DRIVER_LIST, (driver) => + driver.make.pipe(Effect.map((instance) => ({ driver, instance }))), +); -export const layer = Layer.mergeAll( - // `GitHubRest.layerWithDependencies` carries the quota reserve and rate-limit pause every GitHub - // reader shares, so the server builds it once, here. - GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubRest.layerWithDependencies)), - AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), - BitbucketPullRequestApi.layer.pipe( - Layer.provideMerge(BitbucketApi.layer.pipe(Layer.provide(SourceControlRateLimit.layer))), - ), - ForgejoCli.layer, - GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), - GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), -).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); +export const layer = Layer.effect(SourceControlDrivers, make).pipe( + Layer.provideMerge(ServerSourceControlHost.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/sourceControlDefinitions.test.ts b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts new file mode 100644 index 000000000000..e21efd8173a7 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "@effect/vitest"; + +import { + BUILT_IN_SOURCE_CONTROL_DRIVER_LIST, + sourceControlDefinitions, +} from "./sourceControlDefinitions.ts"; + +it("runs a driver for every host the server and clients read, in their order", () => { + expect(BUILT_IN_SOURCE_CONTROL_DRIVER_LIST.map((driver) => driver.definition)).toEqual( + sourceControlDefinitions.definitions, + ); +}); diff --git a/apps/server/src/sourceControl/sourceControlDefinitions.ts b/apps/server/src/sourceControl/sourceControlDefinitions.ts new file mode 100644 index 000000000000..eaeb8dd55f11 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlDefinitions.ts @@ -0,0 +1,19 @@ +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import { builtInSourceControlDefinitions } from "@t3tools/source-control-definitions/registry"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; + +/** In the shared registry's order, which is the order hosts are listed and probed in. */ +export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ + GitHubDriver.driver, + GitLabDriver.driver, + GitCafeDriver.driver, + ForgejoDriver.driver, + BitbucketDriver.driver, + AzureDevOpsDriver.driver, +] as const; + +export const sourceControlDefinitions = builtInSourceControlDefinitions; diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 8ac8046985ce..46c96ca5b7f0 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,26 +1,12 @@ import { secretSettingsFields } from "@t3tools/contracts"; -import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; -import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; -import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; -import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; -import * as GitHub from "@t3tools/source-control-github/client/definition"; -import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; -const definitions: ReadonlyArray = [ - GitHub.definition, - GitLab.definition, - AzureDevOps.definition, - Bitbucket.definition, - Forgejo.definition, - GitCafe.definition, -]; +import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< string, ReadonlyArray > = new Map( - definitions.flatMap((definition) => { + sourceControlDefinitions.definitions.flatMap((definition) => { const fields = definition.settings ? secretSettingsFields(definition.settings) : []; return fields.length > 0 ? [[definition.kind, fields] as const] : []; }), diff --git a/apps/server/src/sourceControl/threadPullRequests.ts b/apps/server/src/sourceControl/threadPullRequests.ts new file mode 100644 index 000000000000..8d9c72b1c6cd --- /dev/null +++ b/apps/server/src/sourceControl/threadPullRequests.ts @@ -0,0 +1,15 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/shared/threadPullRequests"; + +import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; + +export { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadPullRequestChains, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlDefinitions); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 1b411c93cf63..fd56b4a4c86f 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -1,30 +1,75 @@ -import { expect, it } from "@effect/vitest"; +import type * as NodeOS from "node:os"; -import { resolveHeadlessConnectionString } from "./startupAccess.ts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + acceptsPrivateNetworkConnection, + resolveHeadlessConnectionString, +} from "./startupAccess.ts"; + +const ipv4 = (address: string, internal = false): NodeOS.NetworkInterfaceInfo => ({ + address, + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal, + cidr: `${address}/24`, +}); it("resolves wildcard hosts to a concrete external interface when one is available", () => { const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { - en0: [ - { - address: "192.168.1.42", - netmask: "255.255.255.0", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: false, - cidr: "192.168.1.42/24", - }, - ], - lo0: [ - { - address: "127.0.0.1", - netmask: "255.0.0.0", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: true, - cidr: "127.0.0.1/8", - }, - ], + interfaces: { en0: [ipv4("192.168.1.42")], lo0: [ipv4("127.0.0.1", true)] }, }); expect(connectionString).toBe("http://192.168.1.42:3773"); }); + +it("skips public and container addresses for a private-network listener", () => { + const interfaces = { + eth0: [ipv4("203.0.113.7")], + docker0: [ipv4("172.17.0.1")], + bridge200: [ipv4("192.168.64.1")], + eth1: [ipv4("10.0.0.5")], + }; + + expect(resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces })).toBe( + "http://203.0.113.7:3773", + ); + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces, privateNetworkOnly: true }), + ).toBe("http://10.0.0.5:3773"); + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { eth0: [ipv4("203.0.113.7")] }, + privateNetworkOnly: true, + }), + ).toBe("http://localhost:3773"); +}); + +it("prefers a LAN address over a tailnet one for a private-network pairing URL", () => { + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { tailscale0: [ipv4("100.101.102.103")], en0: [ipv4("192.168.1.42")] }, + privateNetworkOnly: true, + }), + ).toBe("http://192.168.1.42:3773"); +}); + +describe("acceptsPrivateNetworkConnection", () => { + const accepts = (localAddress: string | undefined, remoteAddress: string | undefined) => + acceptsPrivateNetworkConnection({ localAddress, remoteAddress }); + + it("serves loopback, LAN, and tailnet clients", () => { + expect(accepts("127.0.0.1", "127.0.0.1")).toBe(true); + expect(accepts("::ffff:192.168.1.20", "::ffff:192.168.1.31")).toBe(true); + expect(accepts("100.101.102.103", "100.90.1.2")).toBe(true); + }); + + it("closes connections from or to a public address", () => { + // A cloud VM's public address is NATed to a private interface address. + expect(accepts("172.31.4.10", "203.0.113.7")).toBe(false); + expect(accepts("203.0.113.7", "192.168.1.31")).toBe(false); + expect(accepts("169.254.10.1", "169.254.10.2")).toBe(false); + expect(accepts(undefined, "192.168.1.31")).toBe(false); + }); +}); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 6f5db469b913..cd2815931e30 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,5 +1,7 @@ +import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; +import { isPrivateNetworkHost, isSharedAddressSpaceHost } from "@t3tools/shared/hostClassification"; import { QrCode } from "@t3tools/shared/qrCode"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; @@ -36,6 +38,38 @@ export const isWildcardHost = (host: string | undefined): boolean => export const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +/** + * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and + * macOS's `bridge100`+) have private addresses only this machine reaches. A + * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so + * it stays listed. + */ +export const VIRTUAL_INTERFACE = + /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/; + +/** + * Loopback or private-range IPv4 (LAN, tailnet, CGNAT), not link-local, in + * plain or IPv4-mapped IPv6 form. + */ +export const isPrivateNetworkAddress = (address: string): boolean => { + const ipv4 = address.replace(/^::ffff:/i, ""); + return NodeNet.isIPv4(ipv4) && !ipv4.startsWith("169.254.") && isPrivateNetworkHost(ipv4); +}; + +/** + * A private-network listener binds every interface but serves only + * connections that stay on loopback, a LAN, or the tailnet. Both ends must be + * private: a cloud VM or a router port forward hands public clients to a + * private local address. + */ +export const acceptsPrivateNetworkConnection = (socket: { + readonly localAddress?: string | undefined; + readonly remoteAddress?: string | undefined; +}): boolean => + [socket.localAddress, socket.remoteAddress].every( + (address) => address !== undefined && isPrivateNetworkAddress(address), + ); + export const loopbackUrlHostForAddress = (address: NetAddress.InetAddress): string => address._tag === "InetAddressV6" && NetAddress.isLoopback(address.address) ? "[::1]" @@ -50,7 +84,8 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || const resolveHeadlessConnectionHost = ( host: string | undefined, - interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), + interfaces: NetworkInterfacesMap, + privateNetworkOnly: boolean, ): string => { if (!host) { return "localhost"; @@ -60,13 +95,26 @@ const resolveHeadlessConnectionHost = ( return normalizeHost(host); } - const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []); - const externalIpv4 = interfaceEntries.find( - (entry) => !entry.internal && isIpv4Family(entry.family), + const interfaceEntries = Object.entries(interfaces).flatMap(([name, entries]) => + privateNetworkOnly && VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []), ); + const externalIpv4Entries = interfaceEntries.filter( + (entry) => + !entry.internal && + isIpv4Family(entry.family) && + (!privateNetworkOnly || isPrivateNetworkAddress(entry.address)), + ); + // A tailnet or other CGNAT address reaches fewer devices than the LAN one. + const externalIpv4 = privateNetworkOnly + ? (externalIpv4Entries.find((entry) => !isSharedAddressSpaceHost(entry.address)) ?? + externalIpv4Entries[0]) + : externalIpv4Entries[0]; if (externalIpv4) { return externalIpv4.address; } + if (privateNetworkOnly) { + return "localhost"; + } const externalIpv6 = interfaceEntries.find( (entry) => !entry.internal && isIpv6Family(entry.family), @@ -77,9 +125,16 @@ const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, - interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), + options: { + readonly interfaces?: NetworkInterfacesMap; + readonly privateNetworkOnly?: boolean | undefined; + } = {}, ): string => { - const connectionHost = resolveHeadlessConnectionHost(host, interfaces); + const connectionHost = resolveHeadlessConnectionHost( + host, + options.interfaces ?? NodeOS.networkInterfaces(), + options.privateNetworkOnly === true, + ); return `http://${formatHostForUrl(connectionHost)}:${port}`; }; @@ -143,6 +198,7 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const connectionString = resolveHeadlessConnectionString( serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), + { privateNetworkOnly: serverConfig.privateNetworkOnly }, ); const issued = yield* serverAuth.issueStartupPairingCredential(); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b8332de49288..c05d4aed4cfb 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -750,7 +750,6 @@ describe("subagent worktree ownership", () => { id: ThreadId.make("grandchild"), lineage: { ...child.lineage, parentThreadId: child.id }, }; - // The worktree owner can follow its children in the snapshot. fixture.setThreads( scenario === "grandchild" ? [grandchild, child, root] diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 471e78df2a3b..0433aaf845e7 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -67,8 +67,7 @@ const decodePnpmModulesManifest = Schema.decodeUnknownEffect( ); const DAY_MS = 86_400_000; -// After a settle, the project's settle script needs time to open its -// terminal, which then protects the worktree. +// Give settle scripts time to open terminals that protect the worktree. const SETTLE_SCRIPT_GRACE_MS = 60_000; // Worktree creation can precede its thread row, including review-guide checkouts. const ORPHAN_WORKTREE_GRACE_MS = 60 * 60 * 1_000; @@ -173,11 +172,7 @@ function worktreeOwner null), ); - // The snapshot takes seconds on large databases, so it runs on the reader - // worker instead of the event loop. + // Large snapshots take seconds; use the reader worker instead of blocking the event loop. const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { const snapshot = yield* projections.getShellSnapshot().pipe(NodeSqliteClient.readOnly); const projects = yield* projectStore.listShells(); @@ -362,7 +356,6 @@ const make = Effect.gen(function* () { projects: ReadonlyArray<{ readonly workspaceRoot: string }>, entries: StorageCleanupReportEntry[], ) { - // Include deleted and archived threads, even those without a branch or project. const rows = yield* sql<{ worktreePath: string }>` SELECT DISTINCT json_extract(payload_json, '$.worktreePath') AS "worktreePath" FROM orchestration_v2_projection_threads @@ -414,9 +407,7 @@ const make = Effect.gen(function* () { } return result; }); - // Let the first walk reach its own limit so unmeasurable folders get cached. - // Later walks share the remaining sweep budget. - // Unmeasured folders still appear with an unknown size. + // Let the first walk time out and cache unmeasurable folders; later walks share the remaining budget. const now = yield* Clock.currentTimeMillis; const measurementDeadline = now + 30_000; let firstMeasurement = true; @@ -482,9 +473,7 @@ const make = Effect.gen(function* () { } }); - // Whether cwd stopped being a checkout of this branch. Two small Git reads, - // since every worktree in a sweep is asked; statusDetailsLocal also diffs and - // counts. + // Use two small Git reads: statusDetailsLocal also diffs and counts for every worktree. const branchChanged = Effect.fn("StorageCleanup.branchChanged")(function* ( cwd: string, branch: string | null, @@ -496,8 +485,7 @@ const make = Effect.gen(function* () { cwd, detail, }); - // Null when cwd is not a repository, or is gone. Git's own messages are - // matched, so they must not be translated. + // Return null for missing/non-repository cwd; force untranslated git diagnostics for matching. const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { const result = yield* git .execute({ @@ -527,8 +515,7 @@ const make = Effect.gen(function* () { return yield* failure( "Git index is locked. Status will resume when the index lock is removed.", ); - // Exits 1 for a detached HEAD. Reads the full ref name, which is never - // shortened to "heads/" when a tag shares the branch name. + // Detached HEAD exits 1; full refs avoid shortening when a tag shares the branch name. const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); if (head === null) return true; const ref = head.exitCode === 0 ? head.stdout.trim() : ""; @@ -809,8 +796,7 @@ const make = Effect.gen(function* () { if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); if (hasTerminal(worktreePath)) return keep("open terminal"); - // Roots are canonical, so compare canonical paths. A symlinked parent - // (a linked drive) is fine; a symlinked worktree directory is not. + // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); if (realPath !== path.join(realParent, path.basename(worktreePath))) @@ -932,10 +918,7 @@ const make = Effect.gen(function* () { ? "not merged" : "has commits beyond the default branch", ); - // Threads, queued deletion work and provider sessions as they are now, so a - // queued turn, resumed session or new thread sharing this path cancels - // the removal. Read only this path's threads: a full snapshot per - // worktree takes seconds on large databases. + // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. const blockedSinceCheck = Effect.fnUntraced(function* () { const latestProjects = yield* projectStore.listShells(); if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) @@ -950,8 +933,7 @@ const make = Effect.gen(function* () { .worktreeOnDelete ) return "thread or cleanup settings changed since check"; - // V2 deletion queues durable cleanup. Do not remove its checkout until - // every effect has finished successfully or was explicitly cancelled. + // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. const pendingCleanup = yield* sql` SELECT 1 FROM orchestration_v2_effect_outbox WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 @@ -989,8 +971,7 @@ const make = Effect.gen(function* () { const sessions = yield* Effect.forEach(sessionRows, (row) => decodeCleanupSession(row.payload_json), ); - // A stored session can stay ready after its runtime was released, - // for example once every thread detached; only a live one holds it. + // Ready persisted sessions may have released their runtime; only live sessions protect the path. for (const session of sessions) { const cwd = path.resolve(session.cwd); if ( @@ -1046,8 +1027,7 @@ const make = Effect.gen(function* () { }); entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); - // Preserve branch and path: ProviderTurnStartService recreates the checkout - // from that branch when the thread is resumed. + // Preserve branch/path so ProviderTurnStartService can recreate the checkout on resume. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( (effect) => withWorkspaceLease(worktreePath, effect), diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 8f402b6682f2..bc0a0ad562a4 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,8 +1,4 @@ -/** - * Events go to the T3 Code ingest endpoint, which keeps a copy and forwards - * them to PostHog. Each event carries a uuid, so PostHog can tell a retried - * copy from a new event. - */ +// T3 ingest retains events and forwards to PostHog; UUIDs deduplicate retries there. import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ClientOs } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -40,8 +36,7 @@ interface DeliveryState { readonly retryAt: number; } -// The background flush checks for work at this interval, so a retry delay -// shorter than it waits for the next check. +// Retry delays shorter than this still wait for the next background check. const FLUSH_INTERVAL_MS = 10_000; // A hung send would hold the flush lock, and with it the shutdown flush. const SEND_TIMEOUT = "10 seconds"; @@ -52,17 +47,11 @@ const RETRY_BASE_DELAY_MS = 2_000; // server's own. const MAX_RECORDED_OWNERS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; -// The endpoint answers these when the batch itself is wrong (bad body, wrong -// api_key, too large), so sending it again cannot help. 408 and 429 are not -// here because a later send can succeed. +// These statuses reject the batch itself; unlike 408/429, retrying cannot help. const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); -/** - * Release channel of this build. Release workflows set it for - * pingdotgg/t3code builds (see apps/server/vite.config.ts); every other build - * reports "dev". It is separate from `__T3CODE_BUILD_CHANNEL__`, which names - * stable "latest" and preview "nightly" for the sign-in page. - */ +// Official release workflows set this telemetry channel; other builds report dev. +// Unlike __T3CODE_BUILD_CHANNEL__, this is not the sign-in page's latest/nightly channel. declare const __T3CODE_TELEMETRY_CHANNEL__: "stable" | "nightly" | "preview" | "dev" | undefined; /** `random` is in [0, 1). */ @@ -133,7 +122,7 @@ function serverOsFromNodePlatform(platform: string): ClientOs { } /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; @@ -234,8 +223,7 @@ export const make = Effect.gen(function* () { return [nextBatch, current.slice(nextBatch.length)] as const; }); - // A rejected batch is dropped at once, but the next batch still waits for the - // backoff, so a wrong api_key does not send every batch as fast as it can. + // Back off even after dropping rejected batches so bad API keys cannot drain the queue in a tight loop. const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { const delivery = yield* Ref.get(deliveryRef); @@ -288,8 +276,7 @@ export const make = Effect.gen(function* () { function* (event, properties) { if (!telemetryConfig.enabled || !distinctId) return; - // Telemetry is best effort: an event without a uuid is not sent. The - // Node implementation throws (a defect) rather than failing, so catch both. + // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. const uuid = yield* Effect.exit(crypto.randomUUIDv7()); if (Exit.isFailure(uuid)) return; diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts index 724a74b5429b..92a54ac8fced 100644 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts +++ b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts @@ -1,8 +1,5 @@ /** - * Records one anonymous `mcp.delegated_task.finished` event when an app-owned - * delegated task reaches a final status, so we can see whether delegation - * between providers actually completes. The event carries the parent and child - * provider and model, the final status, and the child's runtime in seconds. + * Record one anonymous `mcp.delegated_task.finished` event per app-owned task reaching final status. * * @module DelegatedTaskAnalytics */ diff --git a/apps/server/src/telemetry/FeatureUsage.test.ts b/apps/server/src/telemetry/FeatureUsage.test.ts index e19e6a1c8968..784eb670a20e 100644 --- a/apps/server/src/telemetry/FeatureUsage.test.ts +++ b/apps/server/src/telemetry/FeatureUsage.test.ts @@ -1,29 +1,68 @@ -import { ORCHESTRATION_V2_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; +import { + ORCHESTRATION_V2_WS_METHODS, + OrchestrationV2Command, + WS_METHODS, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; -import { featureUsage } from "./FeatureUsage.ts"; +import { commandFeatureUsage, featureUsage } from "./FeatureUsage.ts"; -const dispatch = ORCHESTRATION_V2_WS_METHODS.dispatchCommand; -const threadId = ThreadId.make("thread-private"); +const command = Schema.decodeUnknownSync(OrchestrationV2Command); +const ids = { commandId: "command-private", threadId: "thread-private" }; it("ignores messages, housekeeping commands, and untracked RPCs", () => { - expect(featureUsage(dispatch, { type: "message.dispatch", text: "secret prompt" })).toBe( - undefined, - ); - expect(featureUsage(dispatch, { type: "thread.visit", threadId })).toBe(undefined); + expect( + commandFeatureUsage( + command({ + ...ids, + type: "message.dispatch", + messageId: "message-private", + text: "secret prompt", + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }), + ), + ).toBe(undefined); + expect( + commandFeatureUsage( + command({ ...ids, type: "thread.visit", visitedAt: "2026-10-24T00:00:00.000Z" }), + ), + ).toBe(undefined); expect(featureUsage(WS_METHODS.terminalWrite, { data: "rm -rf" })).toBe(undefined); }); -it("reads RPC variants from enums, never from paths or text", () => { - expect( - featureUsage(WS_METHODS.shellOpenInEditor, { cwd: "/Users/someone/secret", editor: "cursor" }), - ).toEqual({ feature: "editor.open", variant: "cursor" }); +it("leaves thread launches to client.thread.started", () => { expect( featureUsage(ORCHESTRATION_V2_WS_METHODS.launchThread, { title: "Private title", workspaceStrategy: { type: "worktree", baseRef: "main", branch: "secret-branch" }, }), - ).toEqual({ feature: "thread.launch", variant: "worktree" }); + ).toBe(undefined); +}); + +it("tells watching a pull request apart from unwatching it", () => { + const watch = (watching: boolean) => + commandFeatureUsage( + command({ + ...ids, + type: "thread.pull-request.watch", + host: "github.com", + repository: "owner/private", + number: 7, + watching, + }), + ); + expect(watch(true)).toEqual({ feature: "thread.pull_request.watch", variant: "on" }); + expect(watch(false)).toEqual({ feature: "thread.pull_request.watch", variant: "off" }); +}); + +it("reads RPC variants from enums, never from paths or text", () => { + expect( + featureUsage(WS_METHODS.shellOpenInEditor, { cwd: "/Users/someone/secret", editor: "cursor" }), + ).toEqual({ feature: "editor.open", variant: "cursor" }); expect(featureUsage(WS_METHODS.terminalOpen, { cwd: "/Users/someone/secret" })).toEqual({ feature: "terminal.open", }); diff --git a/apps/server/src/telemetry/FeatureUsage.ts b/apps/server/src/telemetry/FeatureUsage.ts index c5f4903c35a1..d8bcfc2e52d4 100644 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ b/apps/server/src/telemetry/FeatureUsage.ts @@ -1,8 +1,4 @@ -import { - ORCHESTRATION_V2_WS_METHODS, - type OrchestrationV2Command, - WS_METHODS, -} from "@t3tools/contracts"; +import { type OrchestrationV2Command, WS_METHODS } from "@t3tools/contracts"; export interface FeatureUsage { readonly feature: string; @@ -18,14 +14,14 @@ const use = (feature: string, variant?: string): FeatureUsage => variant === undefined ? { feature } : { feature, variant }; /** - * User commands sent through `orchestration.dispatchCommand` that represent a - * feature choice. Housekeeping commands (visits, reorders, acknowledgements) - * and `message.dispatch`, which `client.turn.requested` already counts, are - * left out. + * Exclude housekeeping and `message.dispatch`, which `client.turn.requested` already counts. + * Record at command dispatch so retries that commit no change do not count twice. */ -const COMMAND_FEATURES: Partial< - Record FeatureUsage> -> = { +const COMMAND_FEATURES: { + readonly [Type in OrchestrationV2Command["type"]]?: ( + command: Extract, + ) => FeatureUsage; +} = { "checkpoint.rollback": (command) => ({ feature: "checkpoint.rollback", variant: command.restoreFiles === false ? "conversation_only" : "with_files", @@ -38,11 +34,11 @@ const COMMAND_FEATURES: Partial< "thread.pin": () => ({ feature: "thread.pin" }), "thread.settle": () => ({ feature: "thread.settle" }), "thread.pull-request.link": () => ({ feature: "thread.pull_request.link" }), - "thread.pull-request.watch": () => ({ feature: "thread.pull_request.watch" }), - "thread.runtime-mode.set": (command) => - use("thread.runtime_mode.set", literal(command, "runtimeMode")), + "thread.pull-request.watch": (command) => + use("thread.pull_request.watch", command.watching ? "on" : "off"), + "thread.runtime-mode.set": (command) => use("thread.runtime_mode.set", command.runtimeMode), "thread.interaction-mode.set": (command) => - use("thread.interaction_mode.set", literal(command, "interactionMode")), + use("thread.interaction_mode.set", command.interactionMode), "run.interrupt": () => ({ feature: "run.interrupt" }), "queued-message.promote-to-steer": () => ({ feature: "queue.steer" }), "queued-run.edit": () => ({ feature: "queue.edit" }), @@ -51,17 +47,9 @@ const COMMAND_FEATURES: Partial< /** * RPCs that represent a user starting a feature. Variants come only from * closed enums in the contracts, so no path, id, or free text can reach analytics. + * Thread launches are left out because `client.thread.started` counts them. */ const METHOD_FEATURES: Readonly FeatureUsage>> = { - [ORCHESTRATION_V2_WS_METHODS.launchThread]: (payload) => { - const strategy = payload.workspaceStrategy; - return use( - "thread.launch", - typeof strategy === "object" && strategy !== null - ? literal(strategy as Payload, "type") - : undefined, - ); - }, [WS_METHODS.terminalOpen]: () => ({ feature: "terminal.open" }), [WS_METHODS.previewOpen]: () => ({ feature: "preview.open" }), [WS_METHODS.deviceOpen]: () => ({ feature: "device.open" }), @@ -81,13 +69,17 @@ const METHOD_FEATURES: Readonly FeatureUsag [WS_METHODS.serverUpsertKeybinding]: () => ({ feature: "keybinding.customize" }), }; +/** The feature a dispatched command uses, or undefined when it is not a tracked feature. */ +export function commandFeatureUsage(command: OrchestrationV2Command): FeatureUsage | undefined { + // TypeScript cannot pair a union's tag with its own entry, so widen once here. + const mapping = COMMAND_FEATURES[command.type] as + | ((command: OrchestrationV2Command) => FeatureUsage) + | undefined; + return mapping?.(command); +} + /** The feature a WebSocket request uses, or undefined when it is not a tracked feature. */ export function featureUsage(method: string, payload: unknown): FeatureUsage | undefined { if (typeof payload !== "object" || payload === null) return undefined; - const input = payload as Payload; - if (method === ORCHESTRATION_V2_WS_METHODS.dispatchCommand) { - const type = literal(input, "type") as OrchestrationV2Command["type"] | undefined; - return type === undefined ? undefined : COMMAND_FEATURES[type]?.(input); - } - return METHOD_FEATURES[method]?.(input); + return METHOD_FEATURES[method]?.(payload as Payload); } diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 05e657adbe34..0baebc1e1803 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -271,15 +271,10 @@ const readProviderLogins = Effect.fn("TelemetryIdentity.readProviderLogins")(fun }); /** - * `distinctId` keeps the rule every earlier build used, so analytics stay - * continuous: the hashed Codex `account_id`, else the Claude machine `userID`, - * else the install's anonymous id. It can change when someone logs in, and a - * Codex Team shares one. `installId` is the hashed anonymous id, which never - * changes for an install; count installs and people with it. - * - * A null `distinctId` turns telemetry off. A missing file falls through quietly, - * and so does an API-key-only Codex auth.json. Only unreadable or malformed - * files warn. + * `distinctId` preserves legacy priority: hashed Codex account_id, Claude userID, then anonymous id. + * It can change on login or be shared by a Codex Team; hashed `installId` stays stable per install. + * A null `distinctId` disables telemetry. Missing files and API-key-only Codex auth fall through; + * only unreadable or malformed files warn. */ export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHome")(function* ( homeDirectory: string, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 49ee2db831a9..acff38ef00c3 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -826,7 +826,7 @@ it.layer( "done\n"; // Counts reads of strings larger than one emitted chunk, so rescanning the - // pending prefix on every chunk (#23817) shows up as quadratic work. + // pending prefix on every chunk shows up as quadratic work. const charCodeAt = String.prototype.charCodeAt; let largeStringReads = 0; yield* Effect.gen(function* () { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 98ce93d65165..5638f7cd0316 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -192,16 +192,12 @@ export class TerminalManager extends Context.Service< readonly clear: (input: TerminalClearInput) => Effect.Effect; - /** - * Always resets history before spawning the new process. - */ + /** Resets history before spawning. */ readonly restart: ( input: TerminalRestartInput, ) => Effect.Effect; - /** - * When `terminalId` is omitted, closes all sessions for the thread. - */ + /** Omit terminalId to close all sessions for the thread. */ readonly close: (input: TerminalCloseInput) => Effect.Effect; readonly closeIdle: (input: { @@ -665,8 +661,7 @@ function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { const childrenByParent = new Map(); const commandById = new Map(); for (const line of stdout.split(/\r?\n/g)) { - // `comm=` is the final column and may itself contain spaces, so only the - // first two tokens are structural. + // The final comm column can contain spaces; only the first two tokens are structural. const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line); if (!match) continue; const pid = Number(match[1]); @@ -704,8 +699,7 @@ function deriveSubprocessInspectResult( const commandName = (pid: number) => normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform); const shellName = commandName(terminalPid); - // Async prompt themes fork the shell into a helper that waits with no - // children of its own. That copy is not a command the user started. + // Async prompt themes fork childless shell helpers that are not user commands. const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( (pid) => shellName === null || @@ -736,9 +730,7 @@ function deriveSubprocessInspectResult( const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const; -// Resolve `ps` to an absolute path once at startup. Spawning by bare name -// walks every PATH entry per spawn (one failed posix_spawn per directory -// until the hit), which is measurable at a 1s poll cadence on long PATHs. +// Resolve ps once; bare-name spawns repeatedly probe long PATHs during one-second polling. const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () { const fileSystem = yield* FileSystem.FileSystem; for (const candidate of POSIX_PS_ABSOLUTE_PATHS) { @@ -775,8 +767,7 @@ const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot" ), ); if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { - // Not authoritative: an empty or partial table would mark every terminal - // idle and clear its registered process ids. Failing skips the tick. + // Reject partial tables; treating them as authoritative would clear process ids and mark terminals idle. return yield* new TerminalSubprocessCheckError({ command: "ps", exitCode: result.code, @@ -1065,10 +1056,7 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { return true; } - // DECRQM mode queries (…$p) and DECRPM replies (…$y): replaying a stored - // query makes the terminal answer again, and the shell echoes the answer as - // junk at the prompt. The `$` guard keeps setters like DECSTR (!p) and - // DECSCL ("p) intact. + // Strip DECRQM/DECRPM query traffic to prevent echoed replies during replay; $ guards preserve setters. if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) { return true; } @@ -1083,9 +1071,7 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean { return false; } -// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r): -// pure request/response traffic with no visual value, and replaying a stored -// query triggers a fresh reply. +// Strip DECRQSS/XTGETTCAP query traffic; replaying queries triggers fresh replies without visual value. function shouldStripDcsSequence(content: string): boolean { return /^[01]?[$+][qr]/.test(content); } @@ -1322,12 +1308,7 @@ function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { return segment === appDir || segment.startsWith(`${appDir}/`); } -// On Linux AppImage builds the runtime mounts the app under a temporary dir and -// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH. -// The integrated terminal inherits the server process environment, so without -// this scrub those leak into the PTY and tools resolve against the AppImage -// mount instead of the user's real environment (e.g. `php` reporting PHP_BINARY -// as the AppImage path). See issue #1699. +// AppImage mount paths would make PTY tools resolve against the mount instead of the user's system. function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; @@ -1482,7 +1463,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir, providerStatusCacheDir, baseDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; @@ -1530,9 +1511,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; const agentScope = yield* AgentScope; - // Terminals must inherit the user's full environment (minus the blocklist - // applied in createTerminalSpawnEnv) — an allowlist here silently strips - // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // Inherit the full environment minus createTerminalSpawnEnv's blocklist; allowlists silently drop user tooling variables. const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; @@ -1554,9 +1533,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return { ...input, env }; }, ); - // One process-table snapshot per poll tick, shared across every terminal. - // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and - // can exhaust the PID space on hosts with many sessions (#6332). + // Share one process table per poll; per-terminal scans can exhaust PIDs on busy hosts. const fallbackProcessTableSnapshot = ( platform === "win32" ? windowsProcessTableSnapshot() @@ -1697,8 +1674,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func fiber: Fiber.Fiber, ) { yield* modifyManagerState((state) => { - // The child may finish (and run its finalizer) before this registration. - // Check inside the synchronized update so completed fibers are not retained. + // Children may finalize before registration; check inside the synchronized update to avoid retaining finished fibers. if (fiber.pollUnsafe() !== undefined) { return [undefined, state] as const; } @@ -1713,8 +1689,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: string, terminalId: string, ) { - // Subscribe before SIGTERM so an immediate exit is not missed. Released with - // Effect.ensuring because a JS `finally` does not run when the fiber is interrupted. + // Subscribe before SIGTERM to catch immediate exits; Effect.ensuring releases even on interruption, unlike JS finally. const exited = Deferred.makeUnsafe(); const unsubscribeExit = process.onExit(() => { Deferred.doneUnsafe(exited, Effect.void); @@ -2339,9 +2314,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func platform, yield* HostProcess.HomeDirectory, ); - // Append (never prepend) managed ACP agent install directories so - // `kimi login` and friends resolve by name without shadowing any - // system or user tool of the same name. + // Append managed install directories so agent commands resolve without shadowing user/system tools. if ( options.managedBinaryCacheDir !== undefined && options.managedBinaryToolsDir !== undefined @@ -3351,8 +3324,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const marks = new Map( running.map((session) => [session.terminalId, terminalActivityMark(session)]), ); - // Inspect now instead of trusting the last poll, so a command started - // since then keeps its terminal. + // Inspect now so commands started after the last poll still protect their terminals. const { inspector } = yield* acquireSubprocessInspector; yield* Effect.forEach( running, diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 047c24ae8fc3..4b83ddf6697e 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -26,10 +26,7 @@ export class NodePtyModuleLoadError extends Schema.TaggedError Promise; -// node-pty stays external to the CLI bundle because it dlopens a native -// addon. Inside a Node single-executable, `import()` cannot load files from -// disk (only built-ins resolve), while `require` always reads the real -// filesystem, so both the module and its spawn-helper resolve through it. +// node-pty's native addon stays external; single-executables need require to resolve disk modules and spawn helpers. const requireForNodePty = NodeModule.createRequire(import.meta.url); const loadNodePty: NodePtyModuleLoader = () => @@ -81,12 +78,8 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); }); -/** - * node-pty defers creation to avoid blocking on named pipes: - * https://github.com/microsoft/node-pty/pull/885 Its public API has no - * readiness event. The private ready_datapipe handler sets pid before our - * listener runs. - */ +// node-pty has no public readiness event; ready_datapipe sets pid before our listener. +// Deferred pipe creation: https://github.com/microsoft/node-pty/pull/885 const waitForWindowsPid = ( process: import("node-pty").IPty, trackedProcess: NodePtyProcess, @@ -139,10 +132,7 @@ const waitForWindowsPid = ( return Effect.sync(cleanup); }); -/** - * Cancels Windows startup without waiting for the first output, unlike public kill(). - * The private agent can cancel the pending connection before a child exists. - */ +// The private agent cancels pending connections before a child exists; public kill waits for first output. const killStartingWindowsPty = (process: import("node-pty").IPty) => Effect.try(() => { if ( @@ -255,9 +245,7 @@ const make = Effect.fn("NodePtyAdapter.make")(function* () { return PtyAdapter.PtyAdapter.of({ spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; - // node-pty only writes `name` into the child's TERM on the Unix path; - // the ConPTY path leaves the environment untouched, so Windows children - // inherit a missing or 16-color TERM unless it is set here. + // ConPTY ignores node-pty's name option for TERM; set it explicitly to avoid missing/16-color values. const env = platform === "win32" && input.env["TERM"] === undefined ? { ...input.env, TERM: "xterm-256color" } diff --git a/apps/server/src/terminal/nodePtyConsoleList.test.ts b/apps/server/src/terminal/nodePtyConsoleList.test.ts index be538491877c..bcc8313089f4 100644 --- a/apps/server/src/terminal/nodePtyConsoleList.test.ts +++ b/apps/server/src/terminal/nodePtyConsoleList.test.ts @@ -3,9 +3,7 @@ import * as NodeModule from "node:module"; import { afterEach, describe, expect, it } from "vite-plus/test"; -// Exercises the patched console-list helper in patches/node-pty@1.2.0-beta.15.patch. -// The agent calls `require("child_process").fork` at call time, so replacing it on -// the shared CJS module stands in for the forked helper on every platform. +// The agent forks via the shared CJS module at call time; replace it to stub the helper on every platform. const requireCjs = NodeModule.createRequire(import.meta.url); const childProcess: typeof import("node:child_process") = requireCjs("child_process"); const { diff --git a/apps/server/src/testUtils/gitConfig.setup.ts b/apps/server/src/testUtils/gitConfig.setup.ts index a8139563ff3b..f2ad4a83e2d5 100644 --- a/apps/server/src/testUtils/gitConfig.setup.ts +++ b/apps/server/src/testUtils/gitConfig.setup.ts @@ -1,8 +1,4 @@ -// Git for Windows installs with core.autocrlf=true, which checks committed LF -// files out as CRLF and breaks every byte-exact content assertion; a signing -// key or a non-default init branch on the developer's machine breaks fixtures -// the same way. Set as environment so each git child the driver spawns sees it -// without touching the fixtures. +// Override inherited autocrlf, signing and init-branch settings through env so every git child gets stable fixtures. const entries: ReadonlyArray = [ ["core.autocrlf", "false"], ["core.filemode", "false"], diff --git a/apps/server/src/testUtils/weightedShardSequencer.ts b/apps/server/src/testUtils/weightedShardSequencer.ts index 6aed9892443d..51c5130b3a5c 100644 --- a/apps/server/src/testUtils/weightedShardSequencer.ts +++ b/apps/server/src/testUtils/weightedShardSequencer.ts @@ -6,21 +6,12 @@ import { BaseSequencer, type TestSpecification } from "vite-plus/test/node"; import shardWeights from "./shardWeights.json" with { type: "json" }; -// The median recorded file, for files added since the weights were recorded. +// Use the recorded median for new files. const UNRECORDED_FILE_SECONDS = 0.6; const recordedSeconds: Readonly> = shardWeights; -/** - * Splits server test files across `--shard` runs by recorded duration. Vitest's - * default split hashes file paths into equal-count shards, which can put the - * slowest files on one runner. Here the longest file goes to the lightest shard - * first. Every shard derives the same split from the same file list, so each - * file still runs in exactly one shard. Weights are each file's wall time in a - * CI shard, imports included: the gaps between consecutive file results in the - * `Test Server` logs. Imports are about a third of a shard, so test time alone - * leaves the shards with many small files slowest. - */ +// Include import time (~1/3 of CI shards) in weights; derive identical splits so each file runs once. export class WeightedShardSequencer extends BaseSequencer { override async shard(files: TestSpecification[]) { const { index, count } = this.ctx.config.shard ?? { index: 1, count: 1 }; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index b20e07e65e1b..8fa9ccb1c809 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -35,9 +35,9 @@ import { } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; -/** The tools an investigation can see. `--tools` takes bare names; patterns go in the allow list. */ +/** `--tools` takes bare names; patterns go in the allow list. */ const CLAUDE_INVESTIGATION_TOOLS = "Read,Grep,Glob,Bash"; -/** What it may run without asking. In dontAsk mode every other call, Bash included, is refused. */ +/** In dontAsk mode every call outside this list, Bash included, is refused. */ const CLAUDE_INVESTIGATION_ALLOWED = [ "Read", "Grep", @@ -46,10 +46,7 @@ const CLAUDE_INVESTIGATION_ALLOWED = [ "Bash(git log:*)", "Bash(git show:*)", ].join(","); -/** - * The allowed git commands accept any trailing arguments, and `--output` makes each of them write - * a file anywhere. Deny rules win over allow rules, so this keeps the investigation read-only. - */ +// Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere. const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); const ClaudeOutputEnvelope = Schema.Struct({ @@ -70,10 +67,7 @@ const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result const isClaudeErrorResult = Schema.is(ClaudeErrorResult); const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); -/** - * The error a failed `claude -p --output-format json` run reports on stdout. Stderr may only - * hold notices from a wrapper around the binary, so the caller prefers this when present. - */ +// Claude reports JSON errors on stdout; stderr may contain only wrapper notices. function claudeErrorResult(stdout: string): string | undefined { const output = Option.getOrUndefined(decodeJsonOption(stdout)); const messages: ReadonlyArray = Array.isArray(output) ? output : [output]; @@ -223,8 +217,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", - // Claude refuses this flag next to a managed-mcp.json, which already keeps the - // user's own MCP servers out. + // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", "dontAsk", diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b57ed29988c7..a83e09a4110d 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -351,9 +351,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { }, (textGeneration) => Effect.gen(function* () { - // Background fibers (e.g. the worktree branch rename fork) can run - // after their launching request's scope has closed; temp files must - // not be tied to that ambient scope or they are reaped on creation. + // Background fibers can outlive request scopes; temp files scoped there would be reaped on creation. const closedScope = yield* Scope.make(); yield* Scope.close(closedScope, Exit.void); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 20066232105f..17917d793333 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -30,9 +30,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const CODEX_TIMEOUT_MS = 180_000; const CODEX_MCP_LIST_TIMEOUT_MS = 10_000; -// Plugins and apps bring MCP servers of their own; the user's are listed and -// turned off by name below. These follow the user's launch args, so they win -// over a `-c features.plugins=true` there, which `--disable plugins` does not. +// Plugins/apps add MCP servers; use trailing config overrides because --disable loses to user -c flags. const CODEX_NO_PLUGIN_ARGS = [ "--config", "features.plugins=false", @@ -44,10 +42,7 @@ const CodexMcpServers = Schema.fromJsonString( ); const decodeCodexMcpServers = Schema.decodeEffect(CodexMcpServers); -/** - * `mcp_servers={}` merges into the user's servers rather than replacing them, - * so each is named; quoting the name keeps one with a dot a single key. - */ +// mcp_servers={} merges instead of replacing; disable named entries and quote dotted names. const codexMcpServersOff = (names: ReadonlyArray): string | null => names.length === 0 ? null @@ -89,9 +84,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func .remove(path.dirname(filePath), { recursive: true }) .pipe(Effect.catch(() => Effect.void)); - // Deliberately unscoped: text generation runs from background fibers whose - // ambient scope may already be closed (a closed scope reaps the temp - // directory the moment it is created). + // Background fibers may inherit closed scopes that immediately reap temp files; write unscoped. const writeTempFile = ( operation: string, prefix: string, @@ -290,9 +283,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func [ "exec", ...execLaunchArgs, - // Text generation needs only the prompt. A user's MCP server would - // otherwise start and could act on it, as Claude's --strict-mcp-config - // prevents for Claude. + // A user's MCP server could act on the text-generation prompt. ...CODEX_NO_PLUGIN_ARGS, ...(mcpServersOff ? ["--config", mcpServersOff] : []), "--ephemeral", diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts index d45aad1d3430..5840b909acbd 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts @@ -1,7 +1,4 @@ -/** - * (A deny-all session is refused on the free tier: the spike's - * `text_generation` recording.) - */ +/** The free tier refuses deny-all sessions. */ import type { ProviderReplayEntry } from "@t3tools/contracts"; export const OPENCODE2_TITLE_GENERATION: ReadonlyArray = [ diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 7e2d7bfebef4..aedd2cc4077c 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -52,7 +52,7 @@ const resolveInstance = ( ), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 0ba4ecf49b9a..7a068cde85f6 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -46,7 +46,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; @@ -83,9 +83,7 @@ export const make = Effect.gen(function* () { if (changed) yield* PubSub.publish(changes, next); }); - // One refresh at a time: a slow hub read started before a settings change - // must not publish after the change's own refresh and resurrect a removed - // source. Callers queue behind the in-flight run and see current settings. + // Serialize refreshes so slow old-settings reads cannot resurrect removed sources after newer refreshes. const refreshLock = yield* Semaphore.make(1); const refresh = Effect.gen(function* () { const settings = yield* settingsService.getSettings.pipe( diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a7256aabef2b..948d512b6cc4 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -199,9 +199,7 @@ const NARROW_WINDOW: UsageSummaryInput = { untilDay: UsageDay.make("2026-09-02"), }; -/** - * Each waiting gate holds one libuv pool thread; keep at most three. - */ +// Each waiting gate holds a libuv thread; allow at most three. const makeGate = (path: string, lastWriteSeconds?: number) => Effect.promise(async () => { NodeChildProcess.execFileSync("mkfifo", [path]); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 5d46e8288ecc..72d6f8cd42e3 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -70,10 +70,7 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000; const RATES_REFRESH_FLOOR_MS = 60 * 1000; -/** - * Files are filtered by mtime before opening. The slack covers a session whose - * last write lands just before local midnight on the window's first day. - */ +// mtime slack includes writes just before local midnight on the window's first day. const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -127,11 +124,7 @@ const sharedSessionProviders: ReadonlySet = new Set( ), ); -/** - * Only these need cross-file dedupe keys: within one file the occurrence count - * already keeps every key unique, so keying the rest would only build and hash - * a string for each of their records. - */ +// Only shared sessions need cross-file dedupe; per-file occurrence counts make other keys unique. function sharedSessions( files: readonly { readonly records: readonly UsageRecord[] }[], ): ReadonlySet { @@ -386,11 +379,7 @@ export const make = Effect.gen(function* () { return dirs; }); - /** - * `Effect.cached` makes concurrent first readers await the same load rather - * than each seeing a "loaded" flag set before the read finished and cold - * scanning against an empty cache. - */ + // Share first loads so concurrent readers cannot scan against a still-empty cache. const ensureScanCacheLoaded = yield* Effect.cached( Effect.gen(function* () { const readDocument = (filePath: string) => @@ -417,13 +406,10 @@ export const make = Effect.gen(function* () { ); const writeScanCache = makeScanCacheWriter(); - // Scans with different windows can finish together; serializing the writes - // keeps an older snapshot from landing after a newer one. + // Serialize writes so concurrently finished window scans cannot overwrite newer snapshots. const persistLock = yield* Semaphore.make(1); - // The dirty flag is cleared before encoding, so a change while the write is - // in flight marks it dirty again. A failed write restores the flag, so the - // next persist retries instead of leaving disk stale. + // Clear dirty before encoding to retain concurrent changes; restore it on failure for retry. const persistCaches = Effect.gen(function* () { if (cacheDirty) { cacheDirty = false; @@ -450,8 +436,6 @@ export const make = Effect.gen(function* () { const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( Effect.asVoid, ); - // A write still running when the service shuts down finishes first, so the - // next start does not lose the last scan. yield* Effect.addFinalizer(() => awaitPersisted); /** @@ -669,9 +653,7 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ); - // Independent sources scan together. Transcript directories go one at a - // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result - // keeps this order, since aggregation keeps the first copy of a duplicate. + // Scan independent sources together, directories sequentially to bound open files; preserve order for first-copy dedupe. const [transcripts, scanDirs] = yield* Effect.all( [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, @@ -752,8 +734,7 @@ export const make = Effect.gen(function* () { const sources: UsageSource[] = []; - // Cleanup may remove transcripts, but the usage we already saved still - // contributes to its source through the normal aggregation and dedupe path. + // Deleted transcripts' saved usage still aggregates and deduplicates normally. const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); const filesByDir = scannedDirs.map(({ provider, dir, files }) => { const retainedFiles = [...(files ?? [])]; @@ -845,8 +826,7 @@ export const make = Effect.gen(function* () { sharedSessionProviders.has(record.provider) && crossFileSessions.has(record.sessionId) ) { - // Match moved copies without collapsing repeated equal events - // within one file (timestamps can have only second precision). + // Dedupe moved copies but retain repeated equal events within a file, including second-precision timestamps. const key = encodeUsageRecordKey([ record.provider, record.sessionId, @@ -936,14 +916,11 @@ export const make = Effect.gen(function* () { const existing = inflightScans.get(key); if (existing !== undefined) return existing; - // Enrollment and detached-fiber creation must be atomic. Otherwise a - // canceled first caller can leave a Deferred with no scan to finish it. + // Register and fork atomically so cancellation cannot leave a Deferred without a scan. const created = Deferred.makeUnsafe(); inflightScans.set(key, created); - // Detached so one departing client cannot tear the scan out from under - // the fibers awaiting it; a finished scan warms the cache either way. - // The cache write is registered before the waiters resume, so they - // can await it, but its fiber starts after they have the summary. + // Detach so caller departure cannot cancel shared scans; finished scans warm the cache. + // Register writes before releasing waiters, but start writing after delivering their summary. yield* scanSummary(input, settings).pipe( Effect.onExit((exit) => Effect.sync(() => inflightScans.delete(key)).pipe( diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index bca612a044f4..0119adfd940f 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -119,7 +119,6 @@ function fixture( }; } -/** A hub API whose every request fails the way `fail` says. */ const unreachableHub = ( fail: ( request: Parameters[0]>[0], diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 4c981b96dfb6..91504d800229 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -91,8 +91,7 @@ const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(Claud const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); const isUsageLimitSourceError = Schema.is(UsageLimitSourceError); -// Codes that mean the certificate chain is not trusted, which an extra CA can fix. Expired or -// mismatched certificates are reported by code only. +// Extra CAs can fix untrusted chains; expiry/hostname failures report codes only. const UNTRUSTED_CERTIFICATE_CODES = new Set([ "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", @@ -100,7 +99,7 @@ const UNTRUSTED_CERTIFICATE_CODES = new Set([ "UNABLE_TO_VERIFY_LEAF_SIGNATURE", ]); -/** The Node error code under a failed fetch, which wraps it in a generic `TypeError`. */ +// fetch wraps Node error codes in generic TypeErrors. const nodeErrorCode = (cause: unknown) => { for (let error = cause, depth = 0; error instanceof Error && depth < 3; depth += 1) { const code = (error as { readonly code?: unknown }).code; @@ -110,8 +109,7 @@ const nodeErrorCode = (cause: unknown) => { return undefined; }; -// Hub responses and nested error messages can carry keys or tokens, so only the failure's kind, -// HTTP status, or Node error code reaches the client. +// Hub responses can contain credentials; expose only failure kind, HTTP status or Node code. const describeManagementFailure = (error: unknown) => { if (Cause.isTimeoutError(error)) return "The hub did not answer within 15 seconds."; if (HttpClientError.isHttpClientError(error)) { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 65e28d4845a7..17903cd062ea 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -10,10 +10,7 @@ import type { import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; -/** - * `en-CA` yields ISO-ordered parts, which is why it is used here rather than - * assembling the day from `Date` getters (those are host-local only). - */ +// en-CA gives ISO-ordered parts in the requested zone; Date getters are host-local. function makeDayFormatter(timeZone: string): (timestampMs: number) => string { let format: Intl.DateTimeFormat; try { @@ -31,10 +28,7 @@ function makeDayFormatter(timeZone: string): (timestampMs: number) => string { day: "2-digit", }); } - // Formatting once per quarter-hour slot keeps `Intl` (about 2µs a call) off - // the per-record path. Named zones put midnight on a quarter hour, so their - // slots never split; a fixed offset such as `+00:01` can, and records in a - // split slot are formatted exactly. + // Cache quarter-hour slots to avoid per-record Intl calls; fixed-offset midnight can split slots and needs exact formatting. const days = new Map(); return (timestampMs) => { const slot = Math.floor(timestampMs / QUARTER_HOUR_MS); @@ -100,11 +94,7 @@ export interface AggregateResult { readonly outOfWindow: number; } -/** - * De-duplication is global across the whole scan, not per file: Claude Code - * copies a message's records forward when a session is resumed or forked, so - * the same `dedupeKey` legitimately appears in several transcripts. - */ +/** Deduplicates across the whole scan; Claude copies records into resumed/forked transcripts. */ export class UsageAggregator { readonly #buckets = new Map(); readonly #seen = new Set(); @@ -209,7 +199,7 @@ export class UsageAggregator { return true; } - /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */ + // Aliases use the target's rate, replacing provider-specific rateModel. #mapModel(record: UsageRecord): UsageRecord { const model = this.#options.modelAliases?.get(record.model); if (model === undefined) return record; @@ -217,11 +207,7 @@ export class UsageAggregator { return { ...rest, model }; } - /** - * Records arrive file by file in time order, so most land in the bucket the - * previous record used. Checking that first skips building and hashing a - * key string per record. - */ + // Time-ordered records usually reuse the previous bucket; avoid rebuilding its key. #bucketFor( day: string, hourIndex: number, diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 50d839f8175c..9a9f6fba23b4 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -14,10 +14,7 @@ export interface TokenRates { readonly cacheCreationCostPerToken: number; } -/** - * LiteLLM also publishes `*_above_272k_tokens`, `*_flex`, and `*_batches` variants. Transcripts - * don't record those, so we don't price them. - */ +/** Omits LiteLLM's above_272k_tokens, flex and batches variants, which transcripts do not record. */ export interface ModelRate extends TokenRates { readonly fast: TokenRates | null; readonly ultrafast: TokenRates | null; @@ -51,10 +48,7 @@ function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -/** - * Anthropic bills cache reads at a discount and cache writes at a premium. When the standard - * tier omits them, cached input is priced as plain input rather than as free. - */ +// Missing cache rates fall back to input pricing; Anthropic normally discounts reads and charges extra for writes. function readTokenRates( entry: LiteLlmEntry, suffix: string, @@ -95,10 +89,7 @@ function fastMultiplier(entry: LiteLlmEntry): number | null { return fast !== null && fast > 0 ? fast : null; } -/** - * Entries without both an input and an output rate are dropped: a half-priced model would - * silently under-report cost, which is worse than reporting the model as unpriced. - */ +/** Drops models missing input or output rates to avoid understating cost. */ export function parseRateTable(document: unknown): RateTable { const table = new Map(); if (typeof document !== "object" || document === null) return table; @@ -171,21 +162,13 @@ function bareModelName(key: string): string { return slash === -1 ? key : key.slice(slash + 1); } -/** - * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which - * Claude Code writes for the 1M context tier. The rate table only knows the - * base name, and we price at the base tier anyway. - */ +// Price Claude's bracketed context variants at the base tier, the only name the rate table knows. function stripVariantSuffix(key: string): string { const bracket = key.indexOf("["); return bracket === -1 ? key : key.slice(0, bracket); } -/** - * `` marks locally generated messages that were never billed. Bare family names - * ("opus", "sonnet") are genuinely ambiguous across generations, so we report them as unpriced - * instead of guessing a generation. - */ +// Synthetic messages are unbilled; bare family names are generation-ambiguous and remain unpriced. const UNPRICEABLE_MODELS = new Set([ "", "synthetic", @@ -195,10 +178,7 @@ const UNPRICEABLE_MODELS = new Set([ "fable", ]); -/** - * A scan prices every record twice against a few dozen models, and tables are - * never mutated once built. - */ +// Immutable tables permit caching rates across twice-per-record pricing lookups. const resolvedRates = new WeakMap>(); export function lookupRate(table: RateTable, model: string): ModelRate | null { @@ -247,10 +227,7 @@ function sumCategories(cost: UsageCategoryCost): number { return cost.input + cost.cacheRead + cost.cacheWrite + cost.output; } -/** - * `reasoningTokens` is intentionally not charged separately: it is already - * counted inside `outputTokens`. - */ +/** Reasoning tokens are already included in outputTokens; do not charge twice. */ export function priceUsage( table: RateTable, record: PricedRecord, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 3e9f3a92596a..ce8752a867b3 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -33,11 +33,7 @@ export interface CachedFile { readonly mtimeMs: number; readonly provider: UsageProviderKind; readonly records: readonly UsageRecord[]; - /** - * Records from a trailing segment the writer had not newline-terminated at - * parse time. Kept apart from `records` because an incremental parse - * re-reads that segment and would otherwise double count it. - */ + /** Unterminated tail records stay separate because incremental parsing rereads them. */ readonly tailRecords: readonly UsageRecord[]; /** Whole-file count, including diagnostics from the tentative tail. */ readonly malformedRecords: number; @@ -148,11 +144,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { }; } -/** - * Entries are replaced, never mutated, when their file changes, so an entry's JSON is memoised by - * identity. The intern tables only grow, so a memoised entry's indexes stay valid; a pruned entry - * can leave an unused string behind until the next process start. - */ +/** Memoizes immutable entries by identity; growing intern tables preserve indexes, retaining unused strings until restart. */ export function makeScanCacheWriter(): ( cache: ScanCache, extra: Readonly>, @@ -209,9 +201,7 @@ export function decodeScanCache( const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; - // Any corrupt row disqualifies the whole entry. Keeping the survivors - // under the original (size, mtime) would read as a valid warm hit and the - // file would never be re-parsed, silently losing the dropped rows' usage. + // Reject entire corrupt entries; partial survivors would appear warm and hide usage until the file changes. const decodeRecords = ( rows: readonly unknown[], provider: UsageProviderKind, @@ -372,10 +362,7 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** - * Callers stitching an incremental parse together pass one `seen` set across the line and tail - * record batches so the whole file stays deduplicated as a unit; the set is mutated in place. - */ +/** Mutates seen; share it across incremental line/tail batches to deduplicate the whole file. */ export function dedupeWithinFile( records: readonly UsageRecord[], seen: Set = new Set(), diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index f965999fb3da..e7fa7067916c 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -188,8 +188,7 @@ describe("readTranscriptRecords resume", () => { assert.isNotNull(first); assert.strictEqual(first.records.length, 1); - // Codex re-emits an unchanged token_count on stream boundaries; the copy - // lands after the resume point and must still be dropped. + // Codex duplicates token_count at stream boundaries, including after resume points. await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position); assert.isNotNull(second); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 1131b95bc7e9..4f131f310f41 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -1,9 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -/** - * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB - * across ~1,500 files, and buffer-level streaming is roughly an order of - * magnitude cheaper than materialising each file. - */ +// Cold windows span ~1.4 GB/1,500 files; node:fs buffer streaming is ~10x cheaper than materializing files. import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeStringDecoder from "node:string_decoder"; @@ -24,12 +20,7 @@ export interface TranscriptFile { readonly mtimeMs: number; } -/** - * The guard hash fingerprints the bytes immediately before `resumeOffset`. A - * resume only proceeds when those bytes still match: transcripts are - * append-only by design, but a rotated or rewritten file silently mis-parsed - * from the middle would corrupt usage totals. - */ +/** Resume only if bytes before resumeOffset match the guard hash; rewritten files would corrupt totals. */ export interface TranscriptParsePosition { readonly resumeOffset: number; readonly guardLength: number; @@ -39,11 +30,7 @@ export interface TranscriptParsePosition { export interface TranscriptParseResult { readonly records: readonly UsageRecord[]; - /** - * Records from a trailing segment the writer has not newline-terminated yet. - * Kept out of `records` because `position` deliberately excludes that - * segment: the next scan re-reads it once the writer finishes the line. - */ + /** Unterminated tail records stay outside records/position so the next scan rereads them. */ readonly tailRecords: readonly UsageRecord[]; /** Includes tentative tail diagnostics; position.state only includes committed lines. */ readonly malformedRecords: number; @@ -78,12 +65,7 @@ function fnv1a(buffer: Buffer): number { return hash >>> 0; } -/** - * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that - * never carry usage, so the basename filter keeps a cold scan off those files. - * Results keep `readdir` order, which the aggregator's first-seen dedupe relies - * on. - */ +/** Skips Grok logs without usage; preserves readdir order for first-seen deduplication. */ export async function listTranscriptFiles( root: string, sinceMs: number, @@ -160,12 +142,7 @@ async function guardMatches( } } -/** - * Several would be one pass over the line each, so they are matched together as - * one alternation over the line's latin1 view instead: latin1 maps every byte - * to one character without validating it, so an ASCII marker matches exactly - * where its bytes do. - */ +// Match markers in one latin1 alternation; one character per byte preserves exact ASCII offsets. export function transcriptLineGate(markers: readonly [string, ...string[]]) { if (markers.length === 1) { const marker = Buffer.from(markers[0]); @@ -177,15 +154,7 @@ export function transcriptLineGate(markers: readonly [string, ...string[]]) { return (line: Buffer) => pattern.test(line.toString("latin1")); } -/** - * Streams one transcript and returns the usage records it contains, or `null` - * when the file could not be read. - * - * The distinction matters to the caller's cache: a genuinely empty transcript - * is a stable fact worth memoising, while a transient read failure memoised - * under the same `(size, mtime)` key would silently drop that file's usage - * until the file next changes. - */ +/** Returns null on read failure; caching it as empty would hide usage until size/mtime changes. */ export async function readTranscriptRecords( filePath: string, format: TranscriptUsageFormat, @@ -252,8 +221,7 @@ export async function readTranscriptRecords( return; } if (!streaming) { - // Usage has no import-history budget: retain all selected usage fields, - // regardless of the size of the surrounding unselected tool content. + // Usage has no import-history budget; retain selected fields regardless of surrounding tool payload size. streaming = createTranscriptJsonReader(() => {}, selectPath, { maxDepth: Infinity }); decoder = new NodeStringDecoder.StringDecoder("utf8"); for (const pending of pendingChunks) streaming.write(decoder.write(pending)); @@ -304,8 +272,7 @@ export async function readTranscriptRecords( scanOffset += chunk.length; } - // The unfinished tail parses against a copy, so the saved state stays at - // `resumeOffset` and the next scan replays the tail from it. + // Parse unfinished tails against copied state so the next scan can replay from resumeOffset. const encodedState = encodeState === undefined ? null : encodeState(state); const tailRecords: UsageRecord[] = []; const tailState = diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index e10518951132..0e3d2b5063ed 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -262,7 +262,6 @@ it.effect("checkpoint capture keeps the shared index a split index still uses", yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); assert.strictEqual((yield* git(["show", `${checkpointRef}:untracked.txt`])).stdout, "new\n"); - // The real index points at a sharedindex.* file. Reading it fails if the capture expired it. assert.strictEqual((yield* git(["ls-files", "--cached"])).stdout, "file.txt\n"); assert.strictEqual((yield* git(["diff", "--cached", "--name-only"])).stdout, "file.txt\n"); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 3f839643086b..aaa4c382ac7c 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -54,11 +54,7 @@ export interface ExecuteGitInput { readonly forceKillAfterMs?: number; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; - /** - * With `appendTruncationMarker`, keep invoking the line callbacks after the - * buffered copy is full. For long-running commands whose output is only - * consumed through `progress`. - */ + /** With appendTruncationMarker, line callbacks continue beyond the buffer cap for progress-only consumers. */ readonly keepLineCallbacksAfterTruncation?: boolean; readonly progress?: ExecuteGitProgress; } @@ -121,10 +117,7 @@ export interface ExecuteGitProgress { } export interface CreateWorktreeProgress { - /** - * Git refuses an existing path, so a path reported here belongs to this call and is safe to - * remove on cancel. - */ + /** Git refuses existing paths; claimed paths belong to this call and can be removed on cancel. */ readonly onWorktreeClaimed?: (path: string) => Effect.Effect; readonly onCheckoutProgress?: (input: { percent: number; @@ -144,10 +137,7 @@ export interface CreateWorktreeProgress { export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; - /** - * Null (or omitted, for callers without settings access) defers to the - * checkout's own t3.json. - */ + /** Null or omitted defers to the checkout's t3.json. */ readonly submodules?: WorktreeSubmodules | null; readonly worktreesDirectory?: string; } @@ -201,15 +191,10 @@ export interface GitRenameBranchResult { branch: string; } -export interface GitFetchPullRequestBranchInput { +export interface GitFetchRefInput { cwd: string; - prNumber: number; - branch: string; -} - -export interface GitFetchPullRequestHeadCommitInput { - cwd: string; - prNumber: number; + /** A ref on the primary remote, such as a host's change request head ref. */ + ref: string; } export interface GitResolveCommitInput { @@ -224,10 +209,7 @@ export interface GitResolveCommitResult { export interface GitRefreshCheckedOutBranchInput { cwd: string; targetCommit: string; - /** - * Commit the checkout is allowed to be hard-reset away from: the upstream commit read before - * the fetch. HEAD sitting there means the checkout holds no work of its own. - */ + /** Pre-fetch upstream commit; resetting away is safe only if HEAD stayed here. */ resetWhenHeadCommit?: string | null | undefined; } @@ -342,6 +324,14 @@ export class GitVcsDriver extends Context.Service< cwd: string, key: string, ) => Effect.Effect; + /** + * The branch, named as on the host, that T3 recorded `branch` was cut from. Null when none + * was recorded. + */ + readonly readBranchBase: ( + cwd: string, + branch: string, + ) => Effect.Effect; readonly resolveBranchName: ( cwd: string, refName: string, @@ -354,11 +344,12 @@ export class GitVcsDriver extends Context.Service< input: VcsCreateWorktreeInput, options?: CreateWorktreeOptions, ) => Effect.Effect; - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, + /** Force-updates local `branch` to the primary remote's `ref`. */ + readonly fetchRefIntoBranch: ( + input: GitFetchRefInput & { readonly branch: string }, ) => Effect.Effect; - readonly fetchPullRequestHeadCommit: ( - input: GitFetchPullRequestHeadCommitInput, + readonly fetchRefCommit: ( + input: GitFetchRefInput, ) => Effect.Effect; readonly resolveCommit: ( input: GitResolveCommitInput, @@ -806,14 +797,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return path.resolve(physicalCwd, gitCommonDir); }); - // Git renames loose objects and refs into place without fsync by default, so - // an unclean restart can leave 0-byte files under refs/t3/** that break every - // later fetch and push. Checkpoint writes flush before they are published; - // macOS defaults to writeout-only, which does not reach the disk either. + // Git defaults and macOS writeout-only can leave empty refs after crashes; flush before publication. // Set fsync first: Git 2.36 ignores unknown batch without resetting the method. - // Git documents batch/fsync equivalence on macOS HFS+/APFS and Windows NTFS/ReFS; - // keep per-object fsync on other platforms. Batch still fully flushes refs. - // On macOS, VcsProcess retries unsupported full flushes with plain fsync. + // Batch fully flushes refs and equals fsync on macOS HFS+/APFS and Windows NTFS/ReFS. + // Keep per-object fsync elsewhere; VcsProcess retries unsupported macOS full flushes with plain fsync. const durableWrite = [ "-c", "core.fsync=objects,reference", @@ -831,8 +818,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; - // core.splitIndex governs every index Git writes, so an unguarded write to the - // scratch index can expire the sharedindex.* the real .git/index still uses. + // Scratch split-index writes can expire sharedindex files still used by the real index. const indexConfig = [ "-c", "core.fsmonitor=false", @@ -1063,8 +1049,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( Effect.catchTags({ VcsProcessExitError: (error) => Effect.gen(function* () { - // Git cannot stage an embedded repository until it has a commit. Discover these - // only after staging fails so ordinary checkpoints do not need another file scan. + // Git cannot stage uncommitted embedded repos; scan for them only after staging fails. const untracked = yield* execute({ operation, cwd: input.cwd, @@ -1360,8 +1345,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: "error", }).pipe(Effect.map((result) => result.stdout.split("\0"))); - // Remerge diff lists a merge's paths only where the result differs from Git's - // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes. + // Remerge diffs retain merge conflict fixes while excluding clean upstream changes. const pathLists = yield* Effect.all( [ listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d45e1c35da6f..a243d217ebcf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2377,7 +2377,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { it.effect("Changes compares the default branch with its tracking upstream, not origin", () => Effect.gen(function* () { // `origin` is a fork whose main is behind; the checkout tracks the - // canonical repository through a second remote (#15314). + // canonical repository through a second remote. const canonical = yield* makeTmpDir(); yield* initRepoWithCommit(canonical); yield* git(canonical, ["branch", "-M", "main"]); @@ -3012,7 +3012,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.deepEqual(yield* driver.listLocalBranchNames(cwd), branches); assert.isFalse(yield* fileSystem.exists(pathService.join(worktrees, "wt"))); - // A lone "-" is still the previous checkout. yield* git(cwd, ["checkout", "-q", "keep"]); yield* git(cwd, ["checkout", "-q", initialBranch]); assert.equal((yield* driver.switchRef({ cwd, refName: "-" })).refName, "keep"); @@ -3066,7 +3065,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "HEAD"]), ); assert.equal( - yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), + yield* git(cwd, ["config", "branch.feature/parallel.t3-merge-base"]), initialBranch, ); for (const [configured, expected] of [ @@ -3117,7 +3116,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), commit); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/revision.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/revision.t3-merge-base"), null, ); }), @@ -3142,7 +3141,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/branch.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/branch.t3-merge-base"), baseBranch, ); }), @@ -3228,9 +3227,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - // Git refuses `file:` submodule transports by default (CVE-2022-39253) - // and ignores repo-level config for it, so a local fixture needs the - // env allowance. Real submodules are https/ssh and need none of this. + // Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; process.env.GIT_ALLOW_PROTOCOL = "file"; yield* Effect.addFinalizer(() => @@ -3465,7 +3462,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "# test\n", ); assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.t3-merge-base"), null, ); assert.equal(yield* fileSystem.exists(pathService.join(cwd, ".git/config.lock")), true); @@ -3737,7 +3734,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* driver.resolvePrimaryRemoteName(cwd), remoteName); yield* driver.fetchRemoteTrackingBranch({ cwd, remoteName, remoteBranch: initialBranch }); yield* driver.fetchRemote({ cwd, remoteName }); - const head = yield* driver.fetchPullRequestHeadCommit({ cwd, prNumber: 7 }); + const head = yield* driver.fetchRefCommit({ cwd, ref: "refs/pull/7/head" }); assert.isFalse(yield* fileSystem.exists((yield* Path.Path).join(cwd, "t3-marker"))); assert.equal(head.commitSha, yield* git(cwd, ["rev-parse", "HEAD"])); @@ -4171,7 +4168,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.strictEqual(status.branchChanges?.deletions, 0); // gh pr create consumes this setting verbatim as the hosted target branch. assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.t3-merge-base"), "release", ); assert.equal(changes.baseRef, "upstream/release"); @@ -4400,7 +4397,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/feature/x", ); - assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base"), "dev"); const preview = yield* driver.getReviewDiffPreview({ cwd }); const changes = preview.sources.find((source) => source.kind === "branch-range")!; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 58e051b86612..079f5450de1b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -31,7 +31,7 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -50,8 +50,16 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import * as ServerConfig from "../config.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +/** T3's record of the branch, named as on the host, that a branch was cut from. */ +const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base`; +/** + * Where T3 kept that record before it had a key of its own, for every host alike: the GitHub + * CLI's key, which branches cut before then still carry instead. + */ +const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); const WORKTREE_ADD_TIMEOUT_MS = 300_000; const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); @@ -63,14 +71,10 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -// Every status refresh adds the untracked files to a temporary index, and git add's cost grows -// much faster than the file count (50k files took about 30 seconds). Past this many, the -// totals are reported incomplete instead. +// Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; -// Patches the clients render are parsed against git's default a/ and b/ path -// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would -// otherwise leak into the patch and leave every parsed file unnamed. +// Client patch parsers require a/ and b/ prefixes; override user diff prefix settings. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const REVIEW_DIFF_ARGS = [ "diff", @@ -310,12 +314,8 @@ export function splitNullSeparatedGitStdoutPaths( return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated); } -/** - * Refuses a ref name with a leading "-" before it reaches checkout or `worktree add`. Git refuses - * such branch and tag names, and both commands, or the `git branch` that `worktree add` runs, read - * one as an option. `--end-of-options` cannot guard them: Git before 2.44 reads it like `--`. - * A lone "-" is the previous checkout, which both commands read as a ref. - */ +// Checkout/worktree add parse leading dashes as options; pre-2.44 Git cannot guard with --end-of-options. +// A lone - still means previous checkout. const refuseOptionLikeRefName = (operation: string, cwd: string, refName: string | undefined) => refName !== undefined && refName !== "-" && refName.startsWith("-") ? Effect.fail( @@ -430,10 +430,7 @@ function gitCommandContext( } as const; } -// Git states the actual cause on stderr, but stderr never leaves this module: -// it echoes argv and remote URLs, which can carry credentials. Matching it -// against fixed patterns yields a tag the caller can act on and a message that -// quotes none of the matched text. +// Stderr can echo credentials from argv/URLs; classify fixed patterns without exposing matched text. const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray = [ [/would clobber existing tag/i, "tag_would_be_clobbered"], [/is already (?:used by worktree at|checked out at)/i, "branch_checked_out_in_worktree"], @@ -447,8 +444,7 @@ const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray already exists` also - // covers tag and ref collisions, which are not path collisions. + // Unquoted already-exists errors also describe ref/tag collisions; require quoted paths. [/fatal: '[^']+' already exists|destination path .+ already exists/i, "path_already_exists"], // Ambiguous revision/path and pathspec failures can name files, so omit them. // Anchor to a diagnostic line to avoid matching quoted paths in other errors. @@ -486,17 +482,10 @@ function gitFailureDetail(reason: GitCommandFailureReason | null): string { // distinguishes them with a separate error line. const GIT_NOT_A_COMMIT_PATTERN = /^error: .+(?:, not a commit|expected commit type)/im; -// Hooks write to the same stream git does, and nothing distinguishes their text -// from git's: a pre-push hook echoing "authentication failed" would otherwise -// be read as a credential failure. `remote:` is deliberately excluded — git -// prefixes every byte the server sends that way, remote hook output included, -// so trusting it would reintroduce the same false positive from the other end -// of the connection. +// Trust only Git's diagnostic prefixes: local hooks can write indistinguishable text. +// Exclude `remote:` too, since it prefixes remote hook output as well as server diagnostics. const GIT_DIAGNOSTIC_LINE_PATTERN = /^(?:fatal|error):|^!\s|^\s+!\s/; -// ssh reports the refusal itself, unprefixed, and git only adds a generic -// "Could not read from remote repository" after it. Dropping ssh's line would -// leave that generic one to be read as an unreachable remote when the real -// cause is credentials, so these specific refusals are classified too. +// SSH credential refusals are unprefixed; Git's later generic error would otherwise misclassify them as unreachable. const SSH_TRANSPORT_REFUSAL_PATTERN = /Permission denied \((?:publickey|password|keyboard-interactive)|Permission denied, please try again|Host key verification failed/i; @@ -572,9 +561,7 @@ function isUnbornHeadStderr(stderr: string): boolean { ); } -// Matches `git worktree remove` on a path git no longer tracks: "is not a -// working tree" when the registration is gone, "cannot remove working tree" -// when older gits fail validation on a registered-but-deleted directory. +// Git reports missing worktree registrations and registered-but-deleted paths differently. function isMissingWorktreeStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); return ( @@ -583,8 +570,7 @@ function isMissingWorktreeStderr(stderr: string): boolean { ); } -// Fetch stderr can contain remote credentials. Only fixed diagnoses may enter -// persisted errors; unrecognized output keeps the generic failure message. +// Fetch stderr can carry credentials; persist only fixed diagnoses or generic failures. function fetchFailureDetail(stderr: string): string | undefined { const lines = stderr.split(/\r?\n/).map((line) => line.trim()); if ( @@ -620,8 +606,7 @@ function fetchFailureDetail(stderr: string): string | undefined { return undefined; } -// Matches a fetch that lost a ref update to another Git process. Git 2.53+ -// reports it as "fetching ref failed: ..."; older Git says "cannot lock ref". +// Git 2.53+ reports ref races as fetching-ref failures; older versions say cannot lock ref. function isRefLockFailure(stderr: string): boolean { return stderr .split(/\r?\n/) @@ -859,12 +844,10 @@ const collectOutput = Effect.fnUntraced(function* ( let text = ""; let lineBuffer = ""; let truncated = false; - // A separator-free stream past the cap must not grow the line buffer - // without bound; a line longer than this is not one the callbacks want. + // Bound newline-free output; oversized lines are not useful to callbacks. const maxPendingLineBytes = 64 * 1024; - // Git redraws progress with a bare `\r` between updates and only ends the - // line once the step is done, so `\r` has to count as a line break here. + // Git redraws progress with bare CR; treat it as a line break. const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); while (separator) { @@ -942,12 +925,7 @@ const collectOutput = Effect.fnUntraced(function* ( }; }); -/** - * Git on Windows cannot create or delete a path longer than MAX_PATH (260) - * unless `core.longpaths` is set. The OS-level LongPathsEnabled setting does not - * cover it, and git leaves it off by default, so deep worktrees fail to check - * out and fail midway through removal. - */ +/** Windows Git needs core.longpaths beyond MAX_PATH (260); OS LongPathsEnabled alone is insufficient. */ export const windowsLongPathConfigEnv = ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, @@ -990,8 +968,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const baseEnv = { ...process.env, - // Status polling runs beside the user's own git commands; without this, - // `git status` takes index.lock to save its refreshed index. + // Concurrent status polls must not take index.lock to save refreshed indexes. GIT_OPTIONAL_LOCKS: "0", ...input.env, ...trace2Monitor.env, @@ -999,8 +976,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const env = { ...baseEnv, ...explicitBareRepositoryEnv(baseEnv, hostPlatform) }; const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); - // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit - // messages at the first newline; leave that case to Node's lookup. + // cmd wrappers truncate multiline commits; leave git.cmd handling to Node lookup. const executable = resolved.shell ? "git" : resolved.command; const child = yield* commandSpawner .spawn( @@ -1204,10 +1180,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((result) => result.stdout), ); - /** - * Runs `git worktree list --porcelain -z`. Git before 2.36 rejects `-z`, so a failed run - * retries the line form and returns it NUL-delimited, the same shape as `-z` output. - */ + // Pre-2.36 Git rejects worktree list -z; normalize its line fallback to NUL records. const executeWorktreeList = Effect.fn("executeWorktreeList")(function* ( operation: string, cwd: string, @@ -1607,10 +1580,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - // A failed background fetch is intentionally cached and exponentially - // backed off. Status reads swallow this failure and use the last fetched - // refs, so repeated thread mounts cannot turn a slow or unavailable remote - // into a repository-wide Git subprocess storm. + // Cache/back off failed fetches while status uses old refs; repeated mounts must not cause subprocess storms. timeToLive: (exit, cacheKey) => Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL @@ -1739,7 +1709,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "ensureRemote", )(function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); + const normalizedTargetUrl = sourceControlDefinitions.repositoryKeyFromRemote(input.url); const remoteFetchUrls = yield* runGitStdout( "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, @@ -1747,7 +1717,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { + if (sourceControlDefinitions.repositoryKeyFromRemote(remoteUrl) === normalizedTargetUrl) { return remoteName; } } @@ -1829,8 +1799,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const configuredBaseBranch = - (yield* readConfigValue(cwd, `branch.${refName}.gh-merge-base`)) ?? ""; + const configuredBaseBranch = (yield* readBranchBase(cwd, refName)) ?? ""; const configuredBase = yield* resolveComparisonBaseRef(cwd, configuredBaseBranch); if ( configuredBase !== null && @@ -2132,8 +2101,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusCacheKey = repositoryPaths?.gitCommonDir; const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ - // Plumbing, because porcelain `git diff` rewrites the index even with - // GIT_OPTIONAL_LOCKS=0. -M keeps porcelain's rename detection. + // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, @@ -2643,11 +2611,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { - // A branch tracking a differently named ref was cut from it, the way `git - // checkout -b feature origin/dev` and our own worktree flow leave it. - // That upstream is the branch's base, not its publish target, and pushing - // HEAD onto it would write feature commits to a shared branch (bare `git - // push` refuses this under push.default=simple). + // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. const isAliasOfUpstreamHead = branch === currentUpstream.branchName || (branch.endsWith(`/${currentUpstream.branchName}`) && @@ -2659,16 +2623,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const remoteName = publishRemoteName ?? currentUpstream.remoteName; const publishBranch = yield* resolvePublishBranchName(cwd, branch); // Publishing retargets the upstream, so preserve its comparison base first. - const configuredMergeBase = yield* runGitStdout( - "GitVcsDriver.pushCurrentBranch.readMergeBase", - cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (configuredMergeBase.length === 0) { + if ((yield* readBranchBase(cwd, branch)) === null) { yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ "config", - `branch.${branch}.gh-merge-base`, + branchBaseConfigKey(branch), currentUpstream.branchName, ]); if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { @@ -2913,8 +2871,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (indexExists) { const { mtime } = yield* fileSystem.stat(indexPath); yield* fileSystem.copyFile(indexPath, tempIndexPath); - // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. - // Flooring preserves the source second without making preceding-second files racy. + // stat truncates bigint times to ms; floor seconds to avoid making preceding-second files racy. const indexTime = Option.isSome(mtime) ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) : 0; @@ -3390,6 +3347,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const readBranchBase: GitVcsDriver.GitVcsDriver["Service"]["readBranchBase"] = Effect.fn( + "GitVcsDriver.readBranchBase", + )(function* (cwd, branch) { + const base = yield* readConfigValue(cwd, branchBaseConfigKey(branch)); + return base ?? (yield* readConfigValue(cwd, legacyBranchBaseConfigKey(branch))); + }); + const resolveBranchBase = Effect.fn("GitVcsDriver.resolveBranchBase")(function* ( cwd: string, refName: string, @@ -3630,10 +3594,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); const snapshot = refresh || currentEpoch === undefined - ? // The refresh cache owns the complete snapshot read, rather than only the - // epoch bump. Slow repositories therefore remain singleflight for the - // entire Git scan even when more refresh requests arrive after the - // coalescing TTL would otherwise have elapsed. + ? // Cache the entire snapshot read so slow scans stay single-flight beyond the coalescing TTL. yield* Cache.get( listRefsRefreshSnapshotCache, new GitRefsRefreshCacheKey({ gitCommonDir, generation }), @@ -3741,7 +3702,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.newRefName); const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); - // resolve: a cwd like `/sub/..` must not name the folder `..`. + // Resolve sub/.. before taking the basename. const repoName = path.basename(path.resolve(input.cwd)); let worktreePath = input.path; if (worktreePath == null) { @@ -3795,9 +3756,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* progress.onWorktreeClaimed(worktreePath); } - // `git worktree add` leaves submodules empty, so a repo that keeps agent - // skills, tooling or source in one gets a worktree that is quietly missing - // them. + // worktree add leaves submodules empty, potentially omitting project skills, tools or source. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); @@ -3863,17 +3822,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - if (input.newRefName && input.baseRefName) { + const newRefName = input.newRefName; + if (newRefName && input.baseRefName) { const base = yield* resolveBranchBase(input.cwd, input.baseRefName); if (base) { yield* Effect.gen(function* () { yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ "config", - `branch.${input.newRefName}.gh-merge-base`, + branchBaseConfigKey(newRefName), base.branchName, ]); if (base.baseRef.startsWith("refs/remotes/")) { - // gh consumes its key as a provider branch; T3 also needs the selected remote. + // The base key names a branch on the host; this one keeps the remote it came from. yield* runGit("GitVcsDriver.createWorktree.configureComparisonRef", input.cwd, [ "config", `branch.${input.newRefName}.t3-merge-base-ref`, @@ -3899,25 +3859,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = - Effect.fn("fetchPullRequestBranch")(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitVcsDriver.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - "--", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorDetail: "git fetch pull request branch failed", - }, - ); - }); + const fetchRefIntoBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRefIntoBranch"] = Effect.fn( + "GitVcsDriver.fetchRefIntoBranch", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchRefIntoBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + "--", + remoteName, + `+${input.ref}:refs/heads/${input.branch}`, + ], + { + fallbackErrorDetail: "git fetch ref into branch failed", + }, + ); + }); const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( "resolveCommit", @@ -3931,22 +3892,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); - const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] = - Effect.fn("fetchPullRequestHeadCommit")(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a - // branch, which is the only way to read it while that branch is checked out somewhere. - yield* executeGit( - "GitVcsDriver.fetchPullRequestHeadCommit", - input.cwd, - ["fetch", "--quiet", "--no-tags", "--", remoteName, `refs/pull/${input.prNumber}/head`], - { - fallbackErrorDetail: "git fetch pull request head failed", - }, - ); + const fetchRefCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchRefCommit"] = Effect.fn( + "GitVcsDriver.fetchRefCommit", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + // No refspec destination: the ref lands in FETCH_HEAD (per worktree) instead of a branch, + // which is the only way to read it while that branch is checked out somewhere. + yield* executeGit( + "GitVcsDriver.fetchRefCommit", + input.cwd, + ["fetch", "--quiet", "--no-tags", "--", remoteName, input.ref], + { + fallbackErrorDetail: "git fetch ref failed", + }, + ); - return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); - }); + return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = Effect.fn("refreshCheckedOutBranch")(function* (input) { @@ -3970,17 +3932,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["merge-base", "--is-ancestor", headCommit, input.targetCommit], { allowNonZeroExit: true }, ).pipe(Effect.map((result) => result.exitCode === 0)); - // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can - // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD - // never left the commit the upstream held before the fetch. + // Rewritten heads require reset; allow only clean checkouts still at the pre-fetch upstream commit. if (!isAncestor && headCommit !== input.resetWhenHeadCommit) { return { headCommit, moved: false, onTarget: false }; } if (!isAncestor) { - // The commit being reset away is about to be reachable from nothing. It is only ever a - // commit the remote already held, but "the remote held it" stops being a way back once - // the head it belonged to has been rewritten, so a ref keeps it findable. + // Retain the reset-away commit; rewritten remote heads no longer provide a path back. yield* executeGit( "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", input.cwd, @@ -3992,10 +3950,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit( "GitVcsDriver.refreshCheckedOutBranch.move", input.cwd, - // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another - // thread may edit a tracked file between it and this move. Git itself refuses a `--merge` - // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the - // other branch — so a race loses nothing; the refresh fails and is reported instead. + // reset --merge refuses tracked edits racing our cleanliness check, preserving work like --ff-only does. isAncestor ? ["merge", "--ff-only", input.targetCommit] : ["reset", "--merge", input.targetCommit], @@ -4243,12 +4198,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.provideService(Path.Path, path), ); if (result.exitCode === 0) { - // Git for Windows never descends into NTFS junctions, such as pnpm's - // node_modules links. It reports success but leaves them and their parent - // directories behind, so a resumed thread would find a stub instead of - // recreating its checkout. Git has already unregistered the worktree, so - // anything left here is logged rather than returned: a retry could never - // succeed. + // Git for Windows reports success without removing NTFS junctions or their parent directories. + // Log leftover cleanup failures: Git already unregistered the worktree, so removal cannot be retried. if (yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))) { const removed = yield* removeLeftoverLinks(target).pipe( Effect.catch((error) => @@ -4267,9 +4218,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } return; } - // Threads can share a worktree path, and worktrees get removed or pruned - // outside the app, so a worktree that is already gone is a no-op rather - // than an error. + // Threads share paths and outside Git can remove worktrees; already-gone removals are no-ops. const alreadyGone = isMissingWorktreeStderr(result.stderr) && !(yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))); @@ -4473,8 +4422,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe( - // One record per worktree, each ended by an empty field. A `prunable` - // record's directory is gone, and another checkout may now sit there. + // A `prunable` record's directory is gone; another checkout may now sit there. Effect.map(({ stdout }) => stdout.split("\0\0").flatMap((record) => { const fields = record.split("\0"); @@ -4528,13 +4476,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffPreview, getReviewDiffFileContents, readConfigValue, + readBranchBase, resolveBranchName, listRefs, createWorktree: (input, options) => withListRefsInvalidation(input.cwd, createWorktree(input, options)), - fetchPullRequestBranch: (input) => - withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), - fetchPullRequestHeadCommit, + fetchRefIntoBranch: (input) => withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)), + fetchRefCommit, resolveCommit, refreshCheckedOutBranch: (input) => withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index b0273c96da07..83d7abe6a10e 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -14,9 +14,7 @@ import * as VcsProjectConfig from "./VcsProjectConfig.ts"; import * as VcsDriver from "./VcsDriver.ts"; const DETECTION_CACHE_CAPACITY = 2_048; -// Status polls detect every open cwd each tick, and detection costs three git -// processes. `detect` re-checks each hit against the disk, so this TTL only -// bounds rare changes those stats cannot see. +// Detection costs three git processes per cwd; cached hits still stat disk, so TTL bounds only undetectable changes. const DETECTION_CACHE_TTL = Duration.minutes(5); export interface VcsDriverResolveInput { @@ -118,8 +116,7 @@ export const make = Effect.gen(function* () { return yield* detectWithDriver("git", git, input.cwd); }); - // Falls back to the cwd alone when the root is not above it, such as a cwd - // reached through a symlink. + // Symlinks can put cwd outside root; then inspect cwd alone. const foldersBelowRoot = (cwd: string, rootPath: string) => { const relative = path.relative(path.resolve(rootPath), path.resolve(cwd)); if (relative.startsWith("..") || path.isAbsolute(relative)) return [cwd]; diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index d6e9c1ac4375..212415171bdc 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -258,7 +258,7 @@ describe("VcsProcess.run", () => { }), ); - it.effect("bounds a synthetic burst of GitHub API processes", () => + it.effect("bounds a burst of processes that share a concurrency key", () => Effect.gen(function* () { const gate = yield* Deferred.make(); const starts = yield* Queue.unbounded(); @@ -294,10 +294,11 @@ describe("VcsProcess.run", () => { const burst = yield* Effect.all( Array.from({ length: 32 }, (_, index) => service.run({ - operation: `synthetic.github.${index}`, - command: "gh", + operation: `synthetic.cli.${index}`, + command: "host-cli", args: ["api", "user"], cwd: "/workspace", + concurrency: { key: "host-cli", limit: 4 }, }), ), { concurrency: "unbounded" }, @@ -391,78 +392,28 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - const exitWith = (command: string, stderr: string) => - VcsProcess.make.pipe( - Effect.provideService( - ProcessRunner.ProcessRunner, - ProcessRunner.ProcessRunner.of({ - run: () => - Effect.succeed({ - stdout: "", - stderr, - code: ChildProcessSpawner.ExitCode(1), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), - }), - ), - Effect.flatMap((service) => service.run({ ...baseInput, command })), - Effect.flip, - ); - - it.effect("says the branch's rules refused the merge, in this repository's own words", () => + it.effect("reads a failure with the command's own classifier and names it in its terms", () => Effect.gen(function* () { - const providerStderr = - "X Pull request acme/web#7 is not mergeable: the base branch policy prohibits the merge.\nTo have the pull request merged after all the requirements have been met, add the `--auto` flag.\n"; - const error = yield* exitWith("gh", providerStderr); - - expect(error).toMatchObject({ - detail: "The target branch's rules do not allow this merge yet.", - failureKind: "merge-blocked", - }); - expect(error.message).not.toContain(providerStderr); - }), - ); - - it.effect( - "separates a conflict from a rule, since only one of them is the branch's contents", - () => - Effect.gen(function* () { - const error = yield* exitWith( - "gh", - "X Pull request acme/web#7 is not mergeable: the merge commit cannot be cleanly created.\n", - ); - - expect(error).toMatchObject({ - detail: "The two branches conflict, so no merge commit can be created from them.", - failureKind: "merge-conflict", - }); - }), - ); - - it.effect("recognises a pull request somebody else already merged", () => - Effect.gen(function* () { - const error = yield* exitWith("gh", "X Pull request acme/web#7 was already merged\n"); - - expect(error).toMatchObject({ - detail: "The pull request has already been merged.", - failureKind: "already-merged", - }); - }), - ); - - it.effect("leaves other tools the generic reason rather than guessing at their wording", () => - Effect.gen(function* () { - const error = yield* exitWith("glab", "the merge commit cannot be cleanly created"); - - expect(error).toMatchObject({ - detail: "Process exited with a non-zero status.", + const fail = (input: Partial) => + run({ + operation: "test.classify", + command: "node", + args: ["-e", "process.stderr.write('HTTP 404: 404 Not Found'); process.exit(1)"], + cwd: process.cwd(), + ...input, + }).pipe(Effect.flip); + + expect(yield* fail({})).toMatchObject({ failureKind: "command-failed", + detail: "Process exited with a non-zero status.", }); - }), + expect( + yield* fail({ + classifyFailure: (stderr) => (stderr.includes("404") ? "not-found" : null), + notFoundDetail: "Merge request not found.", + }), + ).toMatchObject({ failureKind: "not-found", detail: "Merge request not found." }); + }).pipe(provideLive), ); it.effect("retains spawn causes without exposing process arguments in the error message", () => diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 51c2f2a08fe7..e2055e340053 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -20,6 +20,7 @@ import { VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { SourceControlProcessInput } from "@t3tools/source-control-core/server/SourceControlHost"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -38,6 +39,9 @@ export interface VcsProcessInput { readonly maxOutputBytes?: number; readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"]; readonly appendTruncationMarker?: boolean; + readonly classifyFailure?: SourceControlProcessInput["classifyFailure"]; + readonly notFoundDetail?: string; + readonly concurrency?: SourceControlProcessInput["concurrency"]; } export interface VcsProcessOutput { @@ -61,7 +65,6 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; -const GITHUB_PROCESS_CONCURRENCY = 4; /** Shown to users when a Git command fails because another process holds the index lock. */ export const GIT_INDEX_LOCKED_DETAIL = @@ -91,14 +94,10 @@ const gitMetricOperations = new Set([ ]); /** - * Appends `key=value` to the `GIT_CONFIG_*` entries git reads as command-line - * config, so argv stays the same on every platform and nothing is written to - * the user's config. The entry goes after any inherited ones. Windows env names - * are case-insensitive (elsewhere git reads only `GIT_CONFIG_COUNT`), so there the - * count is found regardless of casing, read from - * the last spelling (a per-command value spread over process.env), and updated - * under every spelling so the child sees it whichever one Node keeps. A count - * git would reject is left alone so git still reports it. + * Append after inherited entries without changing argv or the user's config. + * Windows env keys ignore case: read the last spelling of the count so command overrides win, + * then update every spelling because Node may keep any one. + * Leave invalid counts untouched so Git reports them. */ export const gitConfigEnv = ( env: NodeJS.ProcessEnv, @@ -114,8 +113,7 @@ export const gitConfigEnv = ( if ( inherited !== undefined && inherited !== "" && - // Git parses the count with strtoul: leading whitespace, an optional sign - // (only -0 is non-negative), digits, and nothing after them. + // Match Git's strtoul count syntax, including whitespace, signs and non-negative -0. /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited ) { return {}; @@ -129,25 +127,23 @@ export const gitConfigEnv = ( }; /** - * Git otherwise opens any folder holding `HEAD`, `objects/` and `refs/` as a - * bare repository and applies its `config`, including commands such as - * `core.fsmonitor`. Files saved in a project or checked out from a clone can - * form one, so git started by T3 (or by gh and friends) only opens a bare - * repository named with `--git-dir` or `GIT_DIR`. Git before 2.38 ignores it. + * A project can contain a fake bare repository whose config runs commands such as `core.fsmonitor`. + * Require `--git-dir` or `GIT_DIR` to open bare repositories; Git before 2.38 ignores this setting. */ export const explicitBareRepositoryEnv = (env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => gitConfigEnv(env, "safe.bareRepository", "explicit", platform); -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +const classifyNonZeroExit = ( + stderr: string, + classifyFailure: VcsProcessInput["classifyFailure"], +): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); + const commandKind = classifyFailure?.(stderr) ?? null; if ( + commandKind === "authentication" || normalized.includes("authentication failed") || normalized.includes("not logged in") || - normalized.includes("gh auth login") || - normalized.includes("glab auth login") || - normalized.includes("az devops login") || - normalized.includes("please run az login") || normalized.includes("no oauth token") || normalized.includes("unauthorized") ) { @@ -155,6 +151,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai } if ( + commandKind === "rate-limited" || normalized.includes("api rate limit") || normalized.includes("rate limit exceeded") || normalized.includes("secondary rate limit") || @@ -164,42 +161,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "rate-limited"; } - if ( - (command === "gh" && - (normalized.includes("could not resolve to a pullrequest") || - normalized.includes("repository.pullrequest") || - normalized.includes("no pull requests found for branch") || - normalized.includes("pull request not found"))) || - (command === "glab" && - (normalized.includes("merge request not found") || - normalized.includes("not found") || - normalized.includes("404"))) || - (command === "az" && - normalized.includes("pull request") && - (normalized.includes("not found") || normalized.includes("does not exist"))) - ) { - return "not-found"; - } - - // Why a merge was refused, which is the one failure here a reader can do something about. - // Matched on the host's own wording rather than passed through: the sentence a reader sees is - // written in the contract, and only the reason travels. gh's phrases alone so far, since the rest - // would be guesses, and a wrong reason is worse than the generic one. - if (command === "gh") { - if (normalized.includes("already merged")) { - return "already-merged"; - } - if (normalized.includes("the merge commit cannot be cleanly created")) { - return "merge-conflict"; - } - // Every other refusal gh reports comes through one shape, carrying a reason this does not - // have to enumerate: a branch the host will not merge yet, whatever gh's name for it is. - if (normalized.includes("is not mergeable")) { - return "merge-blocked"; - } - } - - return "command-failed"; + return commandKind ?? "command-failed"; }; // Classify before discarding stderr; keep paths and process output out of errors. @@ -222,7 +184,15 @@ export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcess.Platform; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); - const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY); + const keyedProcesses = new Map(); + const keyedPermit = (concurrency: NonNullable) => { + let semaphore = keyedProcesses.get(concurrency.key); + if (semaphore === undefined) { + semaphore = Semaphore.makeUnsafe(concurrency.limit); + keyedProcesses.set(concurrency.key, semaphore); + } + return semaphore.withPermits(1); + }; const spawn = Effect.fn("VcsProcess.spawn")(function* ( input: VcsProcessInput, @@ -333,7 +303,7 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { - const failureKind = classifyNonZeroExit(input.command, result.stderr); + const failureKind = classifyNonZeroExit(result.stderr, input.classifyFailure); const error = VcsProcessExitError.fromProcessExit( baseError, { @@ -342,9 +312,13 @@ export const make = Effect.gen(function* () { stderrTruncated: result.stderrTruncated, }, failureKind, - input.command === "git" && - failureKind === "command-failed" && - isTransientGitExit(result.stderr), + { + retryable: + input.command === "git" && + failureKind === "command-failed" && + isTransientGitExit(result.stderr), + ...(input.notFoundDetail === undefined ? {} : { notFoundDetail: input.notFoundDetail }), + }, ); if ( input.command === "git" && @@ -392,7 +366,9 @@ export const make = Effect.gen(function* () { }), ); } - return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; + return yield* input.concurrency === undefined + ? bounded + : keyedPermit(input.concurrency)(bounded); }); return VcsProcess.of({ run }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 74d3801c0686..dfd76b1f2b48 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -64,7 +64,7 @@ const logVcsProjectConfigError = (error: VcsProjectConfigError) => }), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index e58099b615db..96674e940f03 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -35,7 +35,7 @@ function resolveRequestedKind( return Effect.succeed(kind); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 41859b9dd907..007a0ea1e317 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -227,7 +227,7 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const automaticFetchInterval = yield* VcsAutomaticFetchInterval; const autoPullPolicy = yield* VcsAutoPullPolicy; @@ -243,7 +243,7 @@ export const make = Effect.gen(function* () { ); const cacheRef = yield* Ref.make(new Map()); // One permit per cwd for remote reads that write the cache. Without it a - // periodic poll that started before `gh pr create` can finish after the + // periodic poll that started before an agent creates a PR can finish after the // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. const remoteWriteLocks = yield* KeyedLock.make(); const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => @@ -460,8 +460,7 @@ export const make = Effect.gen(function* () { errorTag: error._tag, }); if (!pullAttempted) return null; - // A pull or its follow-up reads can fail after local state has changed. - // Let a failed reconciliation reach the caller instead of publishing old status. + // Pull can change local state before failing; propagate reconciliation failures instead of publishing stale status. yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); yield* updateCachedStatus(cwd, local, remote, { publish: true }); @@ -509,8 +508,7 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - // invalidateStatus (not the two partial invalidations) so an explicit - // refresh also bypasses GitManager's slow PR-lookup cache. + // Full invalidation also bypasses GitManager's slow PR cache. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { @@ -544,8 +542,7 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRefresh) return null; - // Resolve the checked-out branch again. A cached PR can belong to - // the previous branch after an agent checks out another branch. + // Re-resolve the branch; cached PRs may belong to a checkout's previous branch. const remote = yield* workflow.remoteStatus( { cwd }, { refreshUpstream: false, refreshMissingPullRequest: true }, diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index c8feb71c3d84..f1140cdcc3a4 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -10,17 +10,9 @@ import type * as GitVcsDriver from "./GitVcsDriver.ts"; const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; /** - * Removes the linked worktree at `worktreePath` by running `git worktree - * remove` through `run`. - * - * Git for Windows before 2.54 follows NTFS junctions while it deletes a - * worktree, and deletes what they point to, such as a shared node_modules. - * Later versions skip junctions but leave them, and the folders holding them, - * behind. On Windows, Git therefore never deletes the files: this makes the - * checks `git worktree remove` makes before deleting (builtin/worktree.c), - * deletes the folder with Node's `rm`, which unlinks junctions without - * following them, and then has Git drop the now-missing worktree's metadata. - * Git elsewhere only unlinks links, so other hosts run Git as before. + * Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind. + * On Windows, mirror Git's removal checks, then delete with Node's `rm`, which + * unlinks junctions without following them. Git then removes the worktree metadata. */ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* < A, diff --git a/apps/server/src/workspace/KnownWorkspaces.ts b/apps/server/src/workspace/KnownWorkspaces.ts index 20df30331029..f442e4cedbb6 100644 --- a/apps/server/src/workspace/KnownWorkspaces.ts +++ b/apps/server/src/workspace/KnownWorkspaces.ts @@ -1,12 +1,4 @@ -/** - * KnownWorkspaces - the folders a client-named cwd may act in: a T3-managed - * worktree folder, a registered project, or a thread's worktree. Clients send - * the thread's worktree, else the project root. A client also sets a thread's - * worktree path, so one counts only under a managed folder or when git lists - * it as a worktree of the thread's project. - * - * @module KnownWorkspaces - */ +// Client-supplied worktrees count only under managed folders or when Git lists them for the project. import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; @@ -35,7 +27,7 @@ export class UnknownWorkspaceError extends Schema.TaggedError { const relative = path.relative(root, candidate); return ( @@ -47,14 +39,14 @@ export const isWithinRoot = (path: Path.Path, candidate: string, root: string) = export class KnownWorkspaces extends Context.Service< KnownWorkspaces, { - /** Whether a symlink-resolved path is inside a known workspace. */ + /** Takes a symlink-resolved path. */ readonly contains: (realPath: string) => Effect.Effect; /** Fails unless `cwd` exists and resolves inside a known workspace. */ readonly assertContains: (cwd: string) => Effect.Effect; } >()("t3/workspace/KnownWorkspaces") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -64,8 +56,7 @@ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; - // A root that does not exist yet keeps its lexical path. One that cannot be - // resolved for another reason, or resolves to a filesystem root, grants nothing. + // Missing roots keep lexical paths; other resolution failures and filesystem roots grant nothing. const resolveRoot = (value: string) => { const resolvedPath = path.resolve(value); return fileSystem.realPath(resolvedPath).pipe( @@ -130,8 +121,7 @@ export const make = Effect.gen(function* () { return real !== null && listed.includes(real); }); for (const { worktreePath, projectId } of threads) { - // A relative path would resolve against the server's own cwd. One under a - // managed folder is still resolved: it can be a symlink to a checkout elsewhere. + // Relative paths use server cwd; managed paths still need resolution to reject symlinks elsewhere. if (!path.isAbsolute(worktreePath)) continue; if (!within(yield* resolveRoot(worktreePath))) continue; const lexical = path.resolve(worktreePath); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 150ac2c69131..730d02c577f4 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -174,7 +174,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; for (const { directoryPath, ignored, except } of [ { directoryPath: "node_modules", ignored: true, except: [] }, - // A force-tracked file and a folder holding tracked files stay unignored. { directoryPath: "dist", ignored: true, except: ["dist/kept.js", "dist/vendored"] }, // git reads `[id]/page.tsx` as a pathspec, which matches the tracked `i/page.tsx`. { directoryPath: "[id]", ignored: true, except: ["[id]/page.tsx"] }, @@ -183,8 +182,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { ? [ // git refuses to classify paths beyond a symlink, so nothing there is labelled. { directoryPath: "node_modules/link", ignored: false, except: [] }, - // A leading `:` is pathspec magic, so git reads `:foo/pkg-0.js` as `foo/pkg-0.js`, - // which `!foo/` keeps unignored. + // Leading : is pathspec magic; Git sees foo/pkg-0.js, unignored by !foo/. { directoryPath: ":foo", ignored: false, except: [] }, ] : []), @@ -203,8 +201,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); it.effect("lists a host folder and its parent, each from its own root", () => - // Folder links outside the workspace browse by passing the host folder as - // `cwd`; walking up passes the parent. Each stays bounded by its own root. Effect.gen(function* () { const parent = yield* makeTempDir(); const path = yield* Path.Path; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index a0d35202c211..5f1c5f2660a6 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -145,7 +145,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -334,8 +334,7 @@ export const make = Effect.gen(function* () { }, catch: toError, }); - // Use stdin so large directories cannot exceed the command-line argument limit. - // Ignore classification is optional in non-git workspaces or when git is unavailable. + // stdin avoids argument limits; ignore classification is optional without git/repositories. const checkIgnore = (paths: ReadonlyArray) => vcsProcess .run({ @@ -349,13 +348,8 @@ export const make = Effect.gen(function* () { maxOutputBytes: 16 * 1024 * 1024, }) .pipe(Effect.orElseSucceed(() => undefined)); - // Git consults the index for every path it classifies, which costs hundreds of - // milliseconds for a `node_modules`-sized folder. Asking about the folder itself answers - // for every child: `check-ignore` exits 0 only when the folder is ignored and nothing - // under it is tracked, and an ignored folder hides all of its children. Git reads paths as - // pathspecs, so a folder whose path has magic (`:foo`) or glob (`[id]`) characters, or - // one reached through a symlink, can get a different answer per child and still asks git - // about each. + // Check ignored folders once to avoid child index lookups; exit 0 guarantees no tracked descendants. + // Symlinks and pathspec magic still need per-child checks. if ( entries.length > IGNORE_CHECK_CHUNK_SIZE && target.relativePath !== "" && diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 9eb2ba758049..0cf5524df4e1 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -26,7 +26,6 @@ vi.mock("node:fs/promises", async (importOriginal) => { return { ...actual, realpath: vi.fn(actual.realpath) }; }); -// Temp folders count as known workspaces unless named as unknown. const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; const layerKnownWorkspacesTest = Layer.mock(KnownWorkspaces.KnownWorkspaces)({ assertContains: (cwd) => @@ -575,7 +574,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, ".git/config", "[core]\n"); - // A bare repository named `.bare`, and a linked worktree's git folder inside it. yield* writeTextFile(cwd, ".bare/HEAD", "ref: refs/heads/main\n"); yield* writeTextFile(cwd, ".bare/objects/info/packs"); yield* writeTextFile(cwd, ".bare/refs/heads/main"); @@ -649,7 +647,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const parentDir = yield* makeTempDir; - // A bare repository with a worktree checked out inside it. const bare = path.join(parentDir, "repo.git"); yield* writeTextFile(bare, "HEAD", "ref: refs/heads/main\n"); yield* writeTextFile(bare, "objects/info/packs"); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 4e7c81404256..13c741b049be 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -28,8 +28,7 @@ import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; -// Windows also reaches `.git` through trailing dots or spaces, a `:` stream -// suffix, or the 8.3 short name `GIT~1`. Names compare upcased, as NTFS does. +// NTFS reaches .git through trailing dots/spaces, stream suffixes and GIT~1; compare uppercased names. const isGitMetadataName = (segment: string) => { const name = (segment.split(":")[0] ?? "").replace(/[. ]+$/, "").toUpperCase(); return name === ".GIT" || name === "GIT~1"; @@ -133,7 +132,7 @@ export class WorkspaceFileSystem extends Context.Service< } >()("t3/workspace/WorkspaceFileSystem") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -196,13 +195,8 @@ export const make = Effect.gen(function* () { ); }; - /** - * Git runs config and hooks from its own folders, so a write never lands in one: - * a `.git` entry, or a repository folder under another name such as `.bare`. - * Folders are checked upward to a linked worktree's top, which holds a `.git` - * file, since worktrees can sit inside a bare repository. `absolutePath` must - * be normalized. - */ + // Reject Git folders, including renamed bare repos whose config/hooks can execute; paths must be normalized. + // Stop at linked worktrees' .git files since worktrees can sit inside bare repos. const isInGitFolder = async (absolutePath: string) => { if (absolutePath.split(path.sep).some(isGitMetadataName)) return true; for (let folder = path.dirname(absolutePath); ; folder = path.dirname(folder)) { @@ -215,8 +209,7 @@ export const make = Effect.gen(function* () { }), ), ); - // What git reads as a repository: HEAD, plus objects and refs here or in - // the folder a `commondir` file names, as in a linked worktree's git folder. + // Git recognizes HEAD with commondir or objects/refs as a repository. if (head && (commondir || (objects && refs))) return true; if (dotGit?.isFile() || path.dirname(folder) === folder) return false; } @@ -357,8 +350,7 @@ export const make = Effect.gen(function* () { return yield* Effect.acquireUseRelease( Effect.tryPromise({ - // Non-blocking so a FIFO cannot hang the open; the stat below rejects - // it. Regular files ignore the flag. Windows lacks it. + // Non-blocking opens prevent FIFO hangs before stat rejects them; Windows lacks the flag. try: () => NodeFSP.open( realTargetPath, @@ -451,12 +443,7 @@ export const make = Effect.gen(function* () { ); }); - /** - * Resolves the file a write targets. The deepest existing ancestor resolves to - * its real path before the root check, so neither the file nor the directories - * created for it can follow a symlink out of the root. A dangling symlink is - * rejected rather than written through. - */ + // Resolve existing ancestors and reject dangling links so creation cannot follow symlinks outside root. const resolveWriteTarget = Effect.fn("WorkspaceFileSystem.resolveWriteTarget")(function* ( input: ProjectWriteFileInput, ) { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 3ae5d524611a..8f2b64cf26a3 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -27,10 +27,7 @@ import type { } from "@t3tools/contracts"; import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; -// fff-node stays external to the CLI bundle because it dlopens a native -// library. A static `import` of an external package is a hard error inside a -// Node single-executable (only built-ins resolve there), so load it through -// `require`, which reads from the real filesystem in every runtime. +// fff-node's native library stays external; single-executables require disk packages instead of importing them. const requireForFff = NodeModule.createRequire(import.meta.url); const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); @@ -257,11 +254,7 @@ function mapContentMatchRanges( })); } -/** - * Whole-word filtering happens after the grep rather than by wrapping the pattern in boundary - * regex: consuming boundaries such as `(?:^|\W)` swallow the separator between adjacent matches - * and widen the reported ranges, and `\b` cannot match punctuation-edged queries at all. - */ +// Post-filter whole words: consuming boundary regexes swallow adjacent separators and widen ranges; \b misses punctuation edges. function isWholeWordRange( line: string, range: { readonly start: number; readonly end: number }, @@ -303,9 +296,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( FileFinder.create({ basePath: cwd, disableMmapCache: true, - // Content indexing costs scan CPU and memory, so only the on-demand - // content-search index pays for it; path-only consumers (file tree, - // composer path search, file picker) keep the lightweight index. + // Content indexing costs CPU/memory; path-only consumers use the lightweight index. disableContentIndexing: variant !== "content", aiMode: false, enableFsRootScanning: true, @@ -490,8 +481,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( )(function* (input) { const { searchQuery, regexMode } = buildContentSearchQuery(input); const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; - // Grep cursors advance by file, so whole-word post-filtering needs enough - // raw candidates from the current file before moving to the next one. + // Grep cursors advance per file; whole-word filtering needs enough raw candidates before advancing. const rawPageSize = input.wholeWord ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) : input.limit; @@ -555,9 +545,7 @@ function parseWorkspaceSearchIndexKey(key: string): { }; } -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const layer = (key: string) => { const { cwd, variant } = parseWorkspaceSearchIndexKey(key); return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts index aef9b8c031c4..e8464eb077f9 100644 --- a/apps/server/src/worktreesDirectory.ts +++ b/apps/server/src/worktreesDirectory.ts @@ -8,11 +8,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerConfig from "./config.ts"; import * as ServerSettings from "./serverSettings.ts"; -/** - * Null when the setting is not an absolute path on this machine, such as `D:\worktrees` - * configured for a Windows server and synced to a Linux one, or when it is a filesystem root, - * which would make every path on that drive look managed. - */ +/** Null for filesystem roots or settings not absolute on this host, including synced foreign-platform paths. */ export function resolveWorktreesDirectory( setting: string, defaultDir: string, @@ -48,19 +44,14 @@ export function managedWorktreesDirectories( return [...directories]; } -/** - * A thread's path and branch can come from a client, so a missing worktree is - * only recreated where the server would have put it: - * `//`, compared by real path. - */ +// Client paths/branches are untrusted; recreate only real paths matching managed-folder/repo/branch. export const makeIsManagedWorktreePath = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; - // Where git would create `target`: the real path of its nearest existing - // ancestor plus the missing rest, since git follows symlinks on the way. + // Match Git's symlink-following location: nearest real ancestor plus missing path components. const realLocation = (target: string): Effect.Effect => fileSystem.realPath(target).pipe( Effect.catch(() => { @@ -74,8 +65,7 @@ export const makeIsManagedWorktreePath = Effect.gen(function* () { return Effect.fn("worktreesDirectory.isManagedWorktreePath")(function* (worktreePath: string) { // git resolves a relative path against the project, not the server cwd. if (!path.isAbsolute(worktreePath)) return false; - // Server-made paths are normalized. A `..` would be applied after git - // follows a symlink but before realPath does, so the two disagree. + // Require normalized paths; Git and realPath disagree on .. after symlink traversal. if (path.resolve(worktreePath) !== worktreePath) return false; const settings = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => ({ diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 787fdc268f4a..ea18643a6881 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -8,6 +8,7 @@ import { WS_METHODS, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, + type OrchestrationV2ThreadShell, type OrchestrationProjectShell, type ServerConfig, type ServerConfigStreamEvent, @@ -68,6 +69,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -290,6 +292,71 @@ it.live.each(["thread", "shell"] as const)( }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); +it.effect("shell snapshots leave idle subagent children out only for clients that ask", () => + Effect.gen(function* () { + const shell = (id: string, parent: string | null, activityRunStatus: "running" | null) => + ({ + id: ThreadId.make(id), + archivedAt: null, + status: activityRunStatus ?? "completed", + activityRunStatus, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + lineage: { + parentThreadId: parent === null ? null : ThreadId.make(parent), + relationshipToParent: parent === null ? null : "subagent", + rootThreadId: ThreadId.make("parent"), + }, + }) as unknown as OrchestrationV2ThreadShell; + const threads = [ + shell("parent", null, null), + shell("idle", "parent", null), + shell("live", "parent", "running"), + ]; + const firstSnapshotThreadIds = (input: { readonly omitIdleSubagents?: boolean }) => + subscribeOrchestrationV2Shell(input).pipe( + Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), + Effect.map(([item]) => + item?.kind === "snapshot" ? item.snapshot.threads.map((thread) => thread.id) : [], + ), + Effect.provideService(ThreadManagement.ThreadManagementService, { + readShellSnapshot: () => + Effect.succeed( + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads, + archivedThreads: [], + }), + ), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectService.ProjectService, { + getShell: () => Effect.succeedNone, + } as unknown as ProjectService.ProjectService["Service"]), + Effect.provideServiceEffect( + ProjectEnrichment.ProjectEnrichmentService, + PubSub.unbounded().pipe( + Effect.map( + (changes) => + ({ + subscribeChanges: PubSub.subscribe(changes), + }) as unknown as ProjectEnrichment.ProjectEnrichmentService["Service"], + ), + ), + ), + ); + + assert.deepEqual(yield* firstSnapshotThreadIds({}), ["parent", "idle", "live"]); + assert.deepEqual(yield* firstSnapshotThreadIds({ omitIdleSubagents: true }), [ + "parent", + "live", + ]); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + // Build the production RPC layer; unrelated capabilities fail if this test calls them. const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ServerSecretStore.ServerSecretStore)({}), @@ -339,6 +406,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(GuestShareService.GuestShareService)({}), Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({}), Layer.mock(ServerSelfUpdate.ServerSelfUpdate)({}), @@ -586,7 +654,6 @@ it.effect("thread resume across compacted history keeps message order and final ) `; } - // Only the event between a's first and newest is deleted. assert.equal((yield* maintenance.compactEventStore).deletedEventCount, 1); const items = yield* subscribeOrchestrationV2Thread({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ff07d93b29b2..2e50d009f7e5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -63,6 +63,7 @@ import { OrchestrationV2ThreadLaunchError, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadStreamItem, type ApplicationStoredEvent, type ProjectEntriesFailure, type ProjectFileFailure, @@ -85,6 +86,7 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + SOURCE_CONTROL_MEDIA_ASSET, ChatAttachmentId, PersistChatAttachmentsError, RpcClientId, @@ -120,6 +122,7 @@ import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts" import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as GuestShareService from "./guest/GuestShareService.ts"; import { archivedShellStreamItemFromThreadShell, buildActiveShellSnapshot, @@ -128,6 +131,7 @@ import { coalesceStoredThreadEvents, composeShellStreamWithEnrichment, dedupeShellEnrichment, + activeShellRowsForSubscriber, loadShellSnapshotParts, shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, @@ -154,9 +158,11 @@ import { THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, } from "./orchestration-v2/threadHistoryPaging.ts"; import { + makeLiveBodyDeduper, projectDomainEventForWire, projectThreadProjectionForWire, } from "./orchestration-v2/WireProjection.ts"; +import { dedupeSnapshotBodies } from "@t3tools/shared/orchestrationV2DedupedBodies"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; @@ -233,7 +239,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -import { featureUsage } from "./telemetry/FeatureUsage.ts"; +import { commandFeatureUsage, featureUsage } from "./telemetry/FeatureUsage.ts"; import type { TelemetryOwner } from "./telemetry/Identify.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -319,8 +325,7 @@ export const withLateEditorConfig = ( editors.join() !== config.availableEditors.join() || (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), ), - // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so - // a timeout here would cancel a probe that outlasts it every time. + // Leave the unshared reveal probe unbounded so timeouts cannot cancel every attempt on slow hosts. Stream.mapEffect((editors) => resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), ), @@ -353,8 +358,7 @@ export const withLateEditorConfig = ( return [{ ...current, providers: event.payload.providers }, [event]]; case "settingsUpdated": return [{ ...current, settings: event.payload.settings }, [event]]; - // Themes and usage-limit sources never ride in a snapshot; clients - // carry their projected values across one. + // Themes and usage-limit sources survive snapshots because snapshots never include them. default: return [current, [event]]; } @@ -574,9 +578,7 @@ const OtherWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isOtherWsRpc)); // Replaying each intervening event costs a shell refetch; past this gap a // single O(active-threads) snapshot is cheaper and bounded. const SHELL_RESUME_MAX_GAP = 1_000; -// Row count alone does not bound replay memory: a few events with large tool -// payloads can decode to gigabytes. Before replaying, sum the serialized -// payload bytes of the range in SQL and reset with a snapshot past this budget. +// Row counts do not bound large tool payloads; sum stored JSON bytes in SQL and snapshot above budget. const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024; function toAuthAccessStreamEvent( @@ -659,8 +661,7 @@ function readClientConnectionOrigin( }; } -// Client telemetry stays in this socket's RPC layer. It must not become a -// server-global "current client" because several client types can connect at once. +// Keep telemetry per socket; several client types can connect simultaneously. function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -790,8 +791,13 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; + readonly acceptDedupedBodies?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const dedupeBodies = input.acceptDedupedBodies === true; + // Replay, snapshot, and live tail share one deduper: it must see every delivered record. + const withLiveDedupe = (stream: Stream.Stream) => + dedupeBodies ? Stream.suspend(() => Stream.map(stream, makeLiveBodyDeduper())) : stream; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; yield* Effect.annotateCurrentSpan({ @@ -838,9 +844,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs afterSequence, throughSequence, limit: THREAD_RESUME_MAX_REPLAY_EVENTS + 1, - // A row a newer build wrote (e.g. after a downgrade) must not fail - // every resubscribe. The live tail resumes from `highWater`, so a - // dropped row never hides later events. + // Downgrades can leave unknown rows; skip them and resume live from highWater so later events survive. skipUnknownEventTypes: true, }) .pipe( @@ -890,21 +894,25 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? buildBoundedThreadStreamSnapshot({ ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, + dedupeBodies, }) - : { - kind: "snapshot" as const, - snapshotSequence, - projection: projectThreadProjectionForWire(snapshot.projection), - }; - return Stream.concat( - Stream.concat(rpcInitialItems([snapshotItem]), completionMarker), - eventStreamFrom(snapshotSequence), + : (() => { + const projection = projectThreadProjectionForWire(snapshot.projection); + return { + kind: "snapshot" as const, + snapshotSequence, + projection: dedupeBodies ? dedupeSnapshotBodies(projection) : projection, + }; + })(); + return withLiveDedupe( + Stream.concat( + Stream.concat(rpcInitialItems([snapshotItem]), completionMarker), + eventStreamFrom(snapshotSequence), + ), ); }); - // The event sink subscribes to live events before reading the persisted - // tail, so no event published during the replay window is lost; overlapping - // events are deduped by sequence on the client. + // Subscribe before reading persisted events to retain concurrent publishes; clients dedupe overlap by sequence. if (input.afterSequence !== undefined) { const highWater = yield* applicationEvents.latestAgentSequence(input.threadId).pipe( Effect.mapError( @@ -936,8 +944,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - // Bound stored JSON before decoding, then check projected event - // size separately. Neither byte count is a bound on process memory. + // Bound stored and projected payload bytes separately; neither bounds process memory. if ( stats.eventCount > THREAD_RESUME_MAX_REPLAY_EVENTS || !isThreadReplayRawPayloadSafe(stats.rawPayloadBytes) @@ -955,8 +962,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - // A retained creation can belong to a thread already deleted. - // Only replace its bounded replay when a snapshot can exist. + // Retained creation events may describe deleted threads; replace replay only if a snapshot exists. if (shell !== null) return yield* snapshotThenLive(); } const replay = yield* loadReplayThrough(input.afterSequence, highWater); @@ -969,9 +975,11 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs if (plan.mode === "snapshot") { return yield* snapshotThenLive(); } - return Stream.concat( - Stream.concat(rpcInitialItems(replay), completionMarker), - eventStreamFrom(highWater), + return withLiveDedupe( + Stream.concat( + Stream.concat(rpcInitialItems(replay), completionMarker), + eventStreamFrom(highWater), + ), ); } @@ -983,6 +991,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc function* (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly omitIdleSubagents?: boolean; }) { const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -1019,14 +1028,15 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc latestSequence: applicationEvents.latestApplicationSequence, }), ); - subagentThreadIds = new Set( - base.threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ); + const rows = activeShellRowsForSubscriber(base.threads, input.omitIdleSubagents === true); + subagentThreadIds = rows.subagentThreadIds; const enriched = yield* enrichProjectShells(base.projects); return { - snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, + snapshot: { + ...base, + threads: rows.threads, + projects: enriched.projects, + } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, }; }); @@ -1122,11 +1132,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc ), ); - // Always attach the enrichment subscription before the first load so - // completions that race HTTP snapshot fetch still push a refresh. We still - // emit one compact metadata refresh up front: getAvailable may have been - // cold on the HTTP path (null identity), and enrichment PubSub events - // published before this subscribe attached are dropped. + // Subscribe before loading for racing enrichments; initial refresh covers pre-subscription completions and cold HTTP snapshots. const completionMarker = input.requestCompletionMarker === true ? Stream.make({ kind: "synchronized" as const }) @@ -1151,9 +1157,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots, }), ); - // Initial unmarked (+ optional same-load marked) always drains first. - // Enrichment merges only with the post-prefix tail so a ready marked - // refresh cannot interleave before the authoritative initial frame. + // Drain initial frames before merging enrichment with live events so marked refreshes cannot precede the initial frame. const completionThenLive = (afterSequence: number) => Stream.concat(completionMarker, liveFrom(afterSequence)); @@ -1251,18 +1255,25 @@ export const layerWsRpc = ( const analytics = yield* AnalyticsService.AnalyticsService; const originProps = clientAnalyticsProps; const recordClientCommandAnalytics = (command: OrchestrationV2Command) => { - switch (command.type) { - case "message.dispatch": - return analytics.record("client.turn.requested", originProps).pipe(Effect.ignore); - default: - return Effect.void; + if (command.type === "message.dispatch") { + return analytics.record("client.turn.requested", originProps).pipe(Effect.ignore); } + const usage = commandFeatureUsage(command); + return usage === undefined + ? Effect.void + : analytics + .record("feature.used", { + ...usage, + ...(originProps.surface === undefined ? {} : { surface: originProps.surface }), + }) + .pipe(Effect.ignore); }; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; + const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; @@ -1358,8 +1369,7 @@ export const layerWsRpc = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const relayClient = yield* RelayClient.RelayClient; - // A webhook URL starts agent runs, so only sessions that may operate - // see it; read-only sessions still see the task itself. + // Webhook URLs start agents; expose them only to operate-scoped sessions while keeping tasks readable. const withVisibleWebhookUrls = (result: ScheduledTaskListResult): ScheduledTaskListResult => currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result @@ -1771,6 +1781,7 @@ export const layerWsRpc = ( }, settings, shellResumeCompletionMarker: true, + shellThreadFamilies: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, threadFind: true, @@ -1817,6 +1828,26 @@ export const layerWsRpc = ( ), ); + // Rows the shell can omit, read with the same snapshot sequence the shell uses. + const readOmittableShellRows = Effect.fn("ws.orchestrationV2.readOmittableShellRows")( + function* ( + options: { readonly subagentFamilyOf: ThreadId } | { readonly worktreePath: string }, + message: string, + ) { + const { threads, snapshotSequence } = yield* loadShellSnapshotParts({ + sql, + readThreads: threadManagement.readShellSnapshot({ location: "active", ...options }), + listProjects: Effect.succeed([]), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( + Effect.mapError( + (cause) => new OrchestrationV2GetShellSnapshotError({ message, cause }), + ), + ); + return { snapshotSequence, threads: threads.threads }; + }, + ); + const subscribeOrchestrationV2ArchivedShell = Effect.fn( "ws.orchestrationV2.subscribeArchivedShell", )(function* () { @@ -1894,7 +1925,9 @@ export const layerWsRpc = ( ).pipe(Effect.provide(intakeContext)), ) .pipe( - Effect.tap(() => recordClientCommandAnalytics(command)), + Effect.tap((result) => + result.replayed ? Effect.void : recordClientCommandAnalytics(command), + ), Effect.map((result) => ({ sequence: result.sequence })), Effect.mapError((cause) => { const detail = userFacingDispatchErrorMessage(cause); @@ -1963,23 +1996,31 @@ export const layerWsRpc = ( ), [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: (_input) => getOrchestrationV2ArchivedShellSnapshot, + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input) => + readOmittableShellRows( + { subagentFamilyOf: input.threadId }, + "Failed to load the thread family", + ), + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: (input) => + readOmittableShellRows( + { worktreePath: input.worktreePath }, + "Failed to load the worktree's threads", + ).pipe(Effect.map(({ threads }) => ({ threadIds: threads.map((thread) => thread.id) }))), [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen( - // Pre-pagination clients still call this compatibility endpoint. - // Keep stale clients from materializing an unbounded transcript. + // Bound compatibility snapshots so pre-pagination clients cannot materialize unlimited transcripts. threadManagement .getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, }) .pipe( - Effect.map((snapshot) => - projectThreadProjectionForWire( + Effect.map( + (snapshot) => buildBoundedThreadProjection({ - projection: snapshot.projection, + projection: projectThreadProjectionForWire(snapshot.projection), snapshotSequence: snapshot.snapshotSequence, }).projection, - ), ), Effect.mapError( (cause) => @@ -2034,17 +2075,23 @@ export const layerWsRpc = ( }).pipe(Effect.provide(intakeContext)), ) .pipe( - Effect.tap(() => - analytics - .record("client.thread.started", originProps) - .pipe( - Effect.andThen( - input.initialMessage === undefined - ? Effect.void - : analytics.record("client.turn.requested", originProps), - ), - Effect.ignore, - ), + // A resumed launch is a retry of one already counted. + Effect.tap((result) => + result.resumed + ? Effect.void + : analytics + .record("client.thread.started", { + ...originProps, + workspaceStrategy: input.workspaceStrategy.type, + }) + .pipe( + Effect.andThen( + input.initialMessage === undefined + ? Effect.void + : analytics.record("client.turn.requested", originProps), + ), + Effect.ignore, + ), ), Effect.map((result) => ({ ...result, @@ -2120,6 +2167,10 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( Effect.andThen(scheduledTasks.rotateWebhookToken(input)), ), + [WS_METHODS.guestShareCreate]: (input) => + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(guestShares.create(input)), + ), [WS_METHODS.secretsAnswerRequest]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen(secretRequests.answer(input)), @@ -2262,8 +2313,7 @@ export const layerWsRpc = ( { concurrency: "unbounded", discard: true }, ); } - // Awaited, not forked: the RPC scope closes on return and would - // interrupt a fork before the hub answered. + // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } @@ -2781,7 +2831,7 @@ export const layerWsRpc = ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || input.resource._tag === "tool-output-image" || - input.resource._tag === "github-media" || + input.resource._tag === SOURCE_CONTROL_MEDIA_ASSET || // A network path is served only from the thread's project, looked up below. (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path) && @@ -2812,8 +2862,7 @@ export const layerWsRpc = ( resource: input.resource, }); } - // A cloned project exists before its files do. Clients ask again - // when the clone lands (see createProjectFaviconUrlAtomFamily). + // Clone projects precede their files; clients retry asset URLs after clone completion. const clone = yield* projectCloneTracker.get(project.value.projectId); return yield* issueAssetUrl({ resource: input.resource, @@ -3038,9 +3087,7 @@ export const layerWsRpc = ( })), ); const providerStatuses = Stream.zipLatestWith( - // The registry stream carries changes only. Seed it with the current - // providers so a source refresh that lands before any provider change - // still pairs up and reaches the client. + // Seed the changes-only stream so early source refreshes pair with current providers. Stream.concat( Stream.fromEffect(providerRegistry.getProviders), providerRegistry.streamChanges, @@ -3053,10 +3100,7 @@ export const layerWsRpc = ( (providers, sources) => usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, ).pipe( - // Both sides replay their current value, so the first pairing normally - // repeats the snapshot the client already holds. Compare against that - // snapshot rather than dropping blindly: a refresh that landed between - // the snapshot and the subscription still goes out. + // Compare initial paired values with the snapshot; blindly dropping them loses changes between snapshot and subscription. (updates) => Stream.concat(rpcInitialItems([config.providers]), updates), Stream.changesWith( (previous, next) => JSON.stringify(previous) === JSON.stringify(next), @@ -3069,10 +3113,7 @@ export const layerWsRpc = ( })), Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); - // Gated on the subscriber's capability flag because an - // already-shipped client decodes this stream against the old - // event union and its whole config subscription dies on an - // unknown member. + // Gate capability-specific events; older clients reject them and lose their config subscription. const environmentThemeUpdates = input.environmentThemes === true ? environmentTheme.streamChanges.pipe( @@ -3194,8 +3235,7 @@ export const layerWsRpc = ( }), ); -// RpcServer's default sends a socket-level Defect frame instead, and the client -// ends every pending request on the socket with it. +// Default socket-level Defect frames end every pending client request. export const WS_RPC_SERVER_OPTIONS = { ...rpcServerTracingOptions, disableFatalDefects: true, @@ -3228,7 +3268,7 @@ export const layer = Layer.unwrap( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; const analytics = yield* AnalyticsService.AnalyticsService; - const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( + const session = yield* serverAuth.authenticateWebSocketUpgrade(request, "rpc").pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid( EnvironmentAuth.serverAuthCredentialReason(error), @@ -3242,7 +3282,6 @@ export const layer = Layer.unwrap( const clientOrigin = readClientConnectionOrigin(request); const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); - yield* analytics.record("client.connected", clientAnalyticsProps); yield* analytics.recordOwners(readClientOwners(request), "client"); const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; @@ -3282,15 +3321,13 @@ export const layer = Layer.unwrap( serverBrowser, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), - // Request fibers run in the handlers' context, so this reporter sees - // their defects, not the rest of the server's. + // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), - // One server-lifetime service means clients share the same PR caches, and a WS - // mutation invalidates the HTTP diff cache that every client reads from. + // Share server-lifetime PR caches so WS mutations invalidate every client's HTTP diffs. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), Layer.provide(OtlpEndpointCheck.layer), Layer.provide( diff --git a/apps/server/src/zipArchive.ts b/apps/server/src/zipArchive.ts index 33c809947388..6711fbd3f5ee 100644 --- a/apps/server/src/zipArchive.ts +++ b/apps/server/src/zipArchive.ts @@ -3,9 +3,7 @@ import * as Effect from "effect/Effect"; import type * as NodeStream from "node:stream"; import * as Yauzl from "yauzl"; -/** - * Close the scope to close the archive. - */ +/** Closing the scope closes the archive. */ export const openZipArchive = Effect.fn("zipArchive.open")(function* ( archivePath: string, makeError: (detail: string, cause: unknown) => E, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 92ad8e9083c3..28ecce1a6986 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -19,9 +19,7 @@ const repoEnv = loadRepoEnv(); const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version) ? "nightly" : "latest"; -// Sent with every analytics event. Release workflows set it for pingdotgg/t3code -// builds only, so local builds and forks report "dev". A typo fails the build -// instead of mislabeling events. +// Official release workflows set the telemetry channel; local builds and forks use "dev". const TELEMETRY_CHANNELS = ["stable", "nightly", "preview", "dev"]; const telemetryChannel = process.env.T3CODE_TELEMETRY_CHANNEL?.trim() || "dev"; if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { @@ -30,10 +28,7 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { ); } -// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe -// step refuses multi-chunk output and counts the sourcemap as a chunk, and the -// executable needs a host Node that supports `--build-sea` (25.7+), so this is -// a separate mode rather than a second entry in the default build. +// tsdown SEA needs one chunk (sourcemaps count) and Node >=25.7, so package separately. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, @@ -81,8 +76,7 @@ export default mergeConfig( }, }, pack: { - // The executable embeds one entry; the history worker becomes a hidden - // subcommand there instead of a sibling script. + // SEA embeds one entry; expose the history worker as a hidden subcommand. entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"], outDir: packExecutable ? "dist-exe" : "dist", sourcemap: !packExecutable, @@ -93,17 +87,13 @@ export default mergeConfig( fileName: "t3", outDir: "dist-exe", ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), - // Node's SEA docs: `import()` does not work when useCodeCache is - // true, and the server reaches several modules that way. + // Node SEA useCodeCache breaks import(), which the server needs. seaConfig: { useCodeCache: false }, }, } : {}), deps: { - // `neverBundle` forces the native packages out: returning false from - // `alwaysBundle` only means "no opinion", so a transitive dependency - // would still be bundled — which silently inlined native loaders such - // as node-gyp-build, losing native acceleration. + // alwaysBundle=false has no opinion; neverBundle must retain transitive native acceleration. alwaysBundle: shouldBundleCliDependency, neverBundle: (id: string) => isExternalCliDependency(id), onlyBundle: false, @@ -133,11 +123,9 @@ export default mergeConfig( }, }, test: { - // The server suite exercises sqlite, git, temp worktrees, and orchestration - // runtimes heavily. Running files in parallel introduces load-sensitive flakes. + // Parallel sqlite/git/worktree/orchestration tests introduce load-sensitive flakes. fileParallelism: false, sequence: { sequencer: WeightedShardSequencer }, - // Appended to the root setup, which mergeConfig concatenates. setupFiles: ["./src/testUtils/gitConfig.setup.ts"], hookTimeout: 120_000, testTimeout: 120_000, diff --git a/apps/web/index.html b/apps/web/index.html index bd4a3d6d5f8a..c6941cd03978 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -122,8 +122,6 @@ }, }, }; - // Keep the built-in mode list in sync with the runtime so the selected - // T3 Chat palette is visible before React has mounted. const BUILT_IN_THEME_MODES = Object.fromEntries( Object.entries(BUILT_IN_THEME_PALETTES).map(([id, palettes]) => [ id, @@ -145,8 +143,6 @@ "t3-ember", "t3-iris", ]; - // Update every theme-color meta so any element another layer added - // carries the resolved color too. const setThemeColor = (color) => { for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { meta.setAttribute("content", color); @@ -219,9 +215,6 @@ try { const storedTheme = window.localStorage.getItem("t3code:theme"); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - // Older builds stored the dark T3 Chat palette as its own theme id - // and every maintainer theme under a t3- prefix; keep those - // preferences readable. Mirrors the runtime's legacy alias table. const LEGACY_THEME_IDS = { "t3-chat-dark": "t3-chat", "t3-grove": "grove", @@ -242,7 +235,6 @@ (mode) => mode === customTheme.appearance || customTheme.variants?.[mode], ) : null); - // Mirrors the runtime's isKnownThemePreference. const isKnownTheme = storedTheme === "light" || storedTheme === "dark" || @@ -256,8 +248,6 @@ THEME_APPEARANCE_MODE_STORAGE_KEY, ); const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY); - // Mirrors the runtime's getThemePreferenceMode fallback: a bare - // "light"/"dark" preference names its own mode. const preferenceMode = theme === "light" || theme === "dark" ? theme : (legacyMode ?? baseAppearance); const appearanceMode = isThemePreferenceMode(storedAppearanceMode) @@ -296,9 +286,7 @@ halfCustomTheme = halfCustom; } } - } catch { - // A malformed mix degrades to the base preference. - } + } catch {} const themeMode = !isThemed ? null : appearanceMode === "system" @@ -385,13 +373,10 @@ } setThemeColor(chromeColor); } catch { - // Mirror the runtime's storage-failure fallback: follow the OS. let prefersDark = true; try { prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - } catch { - // Keep the dark default when even matchMedia is unavailable. - } + } catch {} delete document.documentElement.dataset.themeId; delete document.documentElement.dataset.themeSelected; document.documentElement.classList.toggle("dark", prefersDark); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index cc3b9e5d3b45..5e5b5ff16be9 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -11,11 +11,7 @@ import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { appAtomRegistry, AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; -/** - * The Electron browser host intentionally sits outside the router so its - * webviews survive route transitions, but it must share the same atom registry - * as routed UI. - */ +// Keep webviews outside the router but inside its atom registry so route changes preserve them. export function AppRoot({ router }: { readonly router: AppRouter }) { useEffect( () => diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index c816836ae8ae..6d94ed565c70 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -13,8 +13,7 @@ import { export const DEFAULT_SANS_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; -// Concrete names first: some engines alias `ui-monospace` to the -// proportional system UI font, which would break every code surface. +// Some engines map `ui-monospace` to a proportional font, so prefer concrete names. export const DEFAULT_CODE_FONT_STACK = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; @@ -69,11 +68,7 @@ export interface AppearanceFontPreferences { readonly smoothing: boolean; } -/** - * Sizes are always written: the interface size drives the root font size (and - * with it every rem-based dimension), while the prompt and code sizes stay in - * absolute pixels so they do not scale twice. - */ +/** Always writes sizes: interface size sets the root; prompt and code use pixels to avoid double scaling. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, @@ -139,11 +134,7 @@ function probeWidth(fontList: string): number | null { return fontProbeContext.measureText(FONT_PROBE_TEXT).width; } -/** - * Canvas metric probing instead of document.fonts.check(): check() reports true - * for families that are not installed at all (nothing needs loading), so it - * cannot filter the dropdown. - */ +/** Probes canvas metrics because document.fonts.check() accepts uninstalled families. */ export function isFontFamilyAvailable(family: string): boolean { const families = cssFontFamilies(family); if (families === null) return false; @@ -223,11 +214,7 @@ const MONO_GENERIC_CANDIDATES = [ const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; -/** - * Advance width of the probe text laid out by the DOM - not canvas, whose - * generic-family mapping diverges from real rendering (this engine draws - * `ui-monospace` as the proportional UI font on canvas but not in CSS). - */ +// Canvas generic-family mappings can differ from CSS, so measure the DOM. function measureDomProbeWidth(fontFamily: string): number | null { try { const body = document.body; @@ -263,9 +250,7 @@ function resolveGenericFamilyLabel(generic: string): string | null { return candidate; } } - // No nameable face matched; on Apple platforms that means one of the San - // Francisco faces, which CSS cannot name. Comparing against -apple-system - // tells the UI face apart from SF Mono. + // Unnameable Apple faces need -apple-system metrics to distinguish the UI face from SF Mono. if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { const systemWidth = measureDomProbeWidth("-apple-system"); if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; @@ -299,10 +284,7 @@ export interface InstalledFontFamiliesResult { let installedFamiliesCache: InstalledFontFamiliesResult | null = null; -/** - * Call from a user gesture: the first call raises the browser's local-fonts - * permission prompt. - */ +/** Call from a user gesture; the first call prompts for local-fonts permission. */ export async function queryInstalledFontFamilies(): Promise { if (installedFamiliesCache !== null) return installedFamiliesCache; const query = ( @@ -317,12 +299,10 @@ export async function queryInstalledFontFamilies(): Promise font.family))] - // Dot-prefixed families are macOS-internal UI faces; selecting one is - // never intended and most refuse to render for web content anyway. + // Dot-prefixed macOS UI faces often refuse to render in web content. .filter((family) => !family.startsWith(".")) .sort((left, right) => left.localeCompare(right)); - // A denied permission check resolves with an empty list instead of - // throwing; no machine has zero fonts, so treat empty as denied. + // Denied access can return an empty list instead of throwing. if (families.length === 0) return { families: [], status: "denied" }; installedFamiliesCache = { families, status: "granted" }; return installedFamiliesCache; diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 86823d66b567..65c6f8edd342 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -278,8 +278,7 @@ describe("resolveInitialServerAuthGateState", () => { const requiresAuth = { status: "requires-auth", auth: LOOPBACK_AUTH }; await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); - // The pairing form strips the token before submitting. TanStack history - // reloads the auth gate synchronously when replaceState runs. + // Token removal synchronously reloads the TanStack auth gate via replaceState. const token = takePairingTokenFromUrl(); expect(gateLoads).toHaveLength(1); await expect(gateLoads[0]).resolves.toEqual(requiresAuth); diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index 9a3d4a03b3ca..b6f422c5d68f 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -1,5 +1,4 @@ import { showBootError } from "./lib/bootError"; -// Bundled dev can move UI code into shared chunks. Load it only after this -// entry runs the React refresh preamble, and catch failures before React mounts. +// Shared dev chunks need the React refresh preamble first. Catch failures before React mounts. void import("./main").then(({ startup }) => startup).catch(showBootError); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index fbd843c8d43f..dd5b707cfebd 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -306,7 +306,6 @@ export function BrowserDeviceToolbar({ > - {/* Sticky backing so scrolled controls do not show through the close action. */}