From e5dc0778eeb76f3ab7501a169f31e5c6ed9ddb6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:30:57 -0700 Subject: [PATCH 001/458] docs: establish traced Effect function conventions (#21989) --- .../effect-service-conventions.md | 12 ++++++++++++ AGENTS.md | 6 ++++++ docs/internals/effect-services.md | 16 ++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 624018f7811f..f7cef3929ab1 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -44,6 +44,18 @@ Review changed TypeScript for the conventions below. They apply when a pull requ - Use plain `make` and `layer` in a module named for its implementation (`BunPtyAdapter.ts`). Keep implementation-specific names when one abstract port module holds several implementations (`makeCloudflaredRelayClient`, `layerCloudflared` in `RelayClient.ts`). `infra/relay/src/db.ts` may keep its inline `Layer.succeed(RelayDb, db)`. - When a service moves, delete the old files and update every consumer, including orchestration, MCP, tests, and integration harnesses. Do not leave compatibility re-export shims. +## Effect functions and tracing + +- Reusable functions use named `Effect.fn("Service.operation")`, not functions wrapping + `Effect.gen`. Inline programs, callbacks, and layer construction can keep `Effect.gen`. +- Named `Effect.fn` creates a span; the unnamed form does not. Default to named spans for service + operations and I/O. `Effect.fnUntraced` is deliberate for per-chunk/per-event work, tight loops, + or helpers duplicating an existing tracing boundary; require a nearby reason for new uses. +- Preserve existing span names/options and avoid adding duplicate spans. Pass combinators after + the generator body; transforms receive the original function arguments after the effect. +- Preserve generic signatures, laziness, resource scope, and interruption behavior. An explicit + generator return type is `Effect.fn.Return`. + ## Dependency acquisition and runtime boundaries - Production service construction acquires its Effect dependencies from the environment with `yield* Foo.Foo`, and `make`/`layer` types expose those requirements. Flag a factory that takes `Foo["Service"]` (or an object of Effect-returning methods) as a parameter when that value is a service dependency. Passing service instances explicitly in tests is fine; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. diff --git a/AGENTS.md b/AGENTS.md index 810d26e31341..4dc489fafa68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,12 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - Client mutations use `createEnvironmentRpcCommand`. Add protected methods to `CLIENT_GUARDED_RPC_SCOPES` in contracts and use the command's `permissionAtom` for UI availability. Grants are checked at execution against the destination environment; the server remains authoritative. Keep raw RPC clients inside `rpc/`, and extend the permission behavior tests when adding a protected method. - Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md). - `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md). +- Reusable Effect functions use `Effect.fn("Service.operation")` with a stable span name, not a + function wrapping `Effect.gen`. Keep `Effect.gen` for inline programs and layer construction. + Use `Effect.fnUntraced` only when spans would be too frequent (stream chunks, per-event + projection, tight loops) or duplicate an existing tracing boundary; explain that choice nearby. + 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. - 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. diff --git a/docs/internals/effect-services.md b/docs/internals/effect-services.md index c29016dbf85d..f2d93b9d8e84 100644 --- a/docs/internals/effect-services.md +++ b/docs/internals/effect-services.md @@ -90,6 +90,22 @@ export const layer = Layer.effect(Foo, make); - **Tests** exercise behavior through the service, with test layers only for external dependencies. Don't mock the logic under test. +## Effect functions and tracing + +Reusable functions use `Effect.fn("Service.operation")`, not an arrow or declaration returning +`Effect.gen`. The named form creates a span; `Effect.fn(function* ...)` only captures stack +frames. Keep `Effect.gen` for inline programs, callbacks, and layer construction. + +Default to named spans for service operations and I/O. Use `Effect.fnUntraced` deliberately for +per-chunk/per-event work, tight loops, or helpers already covered by the same operation's span. +Explain the reason beside the function or a group of related functions. A loop is not by itself +an exemption: its outer operation can still have a useful span without tracing each iteration. +Preserve existing span names and options instead of adding a second span for the same operation. + +Pass effect combinators after the generator body. A transform that needs the function's input +receives it after the effect. Use `Effect.fn.Return` when the generator needs an explicit +return type, and preserve generic parameters, laziness, scope, and interruption behavior. + ## Runtime boundaries `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at application and framework From a8e8e7ee1152c48aa89b2b37d3109777a541e687 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:30:58 -0700 Subject: [PATCH 002/458] refactor(effect): trace shared and source-control operations (#21990) --- packages/shared/src/DrainableWorker.ts | 81 ++-- packages/shared/src/KeyedCoalescingWorker.ts | 165 ++++--- packages/shared/src/KeyedLock.ts | 69 +-- packages/shared/src/Net.ts | 48 +- packages/shared/src/atomicWrite.test.ts | 9 +- packages/shared/src/devHome.ts | 62 ++- packages/shared/src/shell.ts | 5 +- packages/shared/src/symlink.ts | 53 +- .../server/AzureDevOpsPullRequestProvider.ts | 54 ++- .../src/server/BitbucketApi.ts | 164 +++---- .../server/ForgejoSourceControlProvider.ts | 48 +- .../src/server/GitCafePullRequestProvider.ts | 41 +- .../src/server/GitHubPullRequestApi.ts | 456 +++++++++--------- .../src/server/GitHubRequestPolicy.ts | 158 +++--- .../src/server/GitHubSourceControlProvider.ts | 53 +- .../src/server/gitHubChangeRequestTemplate.ts | 18 +- .../src/server/gitHubConditionalChecks.ts | 96 ++-- .../src/server/gitHubGraphQlClient.ts | 193 ++++---- .../src/server/GitLabPullRequestCli.ts | 258 +++++----- packages/tailscale/src/tailscale.ts | 239 +++++---- 20 files changed, 1150 insertions(+), 1120 deletions(-) diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 4515e5ca626d..6f994068a5dd 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -15,50 +15,53 @@ export interface DrainableWorker { * closes instead of waiting on them. An item that fails or dies is logged and skipped; the worker * keeps processing later items and `drain` still resolves. */ -export const makeDrainableWorker = ( +export const makeDrainableWorker = Effect.fn("DrainableWorker.makeDrainableWorker")(function* < + A, + E, + R, +>( process: (item: A) => Effect.Effect, -): Effect.Effect, never, Scope.Scope | R> => - Effect.gen(function* () { - const outstanding = yield* TxRef.make(0); - const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), (queue) => - // Uncount only the dropped items: an item still running uncounts itself, - // even when a parallel scope closes it after this finalizer. - TxQueue.clear(queue).pipe( - Effect.flatMap((dropped) => TxRef.update(outstanding, (n) => n - dropped.length)), - Effect.andThen(TxQueue.shutdown(queue)), - Effect.tx, - ), - ); +): Effect.fn.Return, never, Scope.Scope | R> { + const outstanding = yield* TxRef.make(0); + const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), (queue) => + // Uncount only the dropped items: an item still running uncounts itself, + // even when a parallel scope closes it after this finalizer. + TxQueue.clear(queue).pipe( + Effect.flatMap((dropped) => TxRef.update(outstanding, (n) => n - dropped.length)), + Effect.andThen(TxQueue.shutdown(queue)), + Effect.tx, + ), + ); - yield* TxQueue.take(queue).pipe( - Effect.flatMap((a) => - // `suspend` turns a `process` that throws while building its effect - // into this item's defect instead of the loop's. - Effect.suspend(() => process(a)).pipe( - // Only the item's own failure, defect, or interruption lands here and - // the loop continues; interrupting the worker fiber still stops it. - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : Effect.logError("DrainableWorker item failed", cause), - ), - Effect.ensuring(TxRef.update(outstanding, (n) => n - 1)), + yield* TxQueue.take(queue).pipe( + Effect.flatMap((a) => + // `suspend` turns a `process` that throws while building its effect + // into this item's defect instead of the loop's. + Effect.suspend(() => process(a)).pipe( + // Only the item's own failure, defect, or interruption lands here and + // the loop continues; interrupting the worker fiber still stops it. + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("DrainableWorker item failed", cause), ), + Effect.ensuring(TxRef.update(outstanding, (n) => n - 1)), ), - Effect.forever, - Effect.forkScoped, - ); + ), + Effect.forever, + Effect.forkScoped, + ); + + const drain: DrainableWorker["drain"] = TxRef.get(outstanding).pipe( + Effect.flatMap((n) => (n > 0 ? Effect.txRetry : Effect.void)), + Effect.tx, + ); - const drain: DrainableWorker["drain"] = TxRef.get(outstanding).pipe( - Effect.flatMap((n) => (n > 0 ? Effect.txRetry : Effect.void)), + const enqueue = (element: A): Effect.Effect => + TxQueue.offer(queue, element).pipe( + Effect.tap((offered) => (offered ? TxRef.update(outstanding, (n) => n + 1) : Effect.void)), Effect.tx, ); - const enqueue = (element: A): Effect.Effect => - TxQueue.offer(queue, element).pipe( - Effect.tap((offered) => (offered ? TxRef.update(outstanding, (n) => n + 1) : Effect.void)), - Effect.tx, - ); - - return { enqueue, drain } satisfies DrainableWorker; - }); + return { enqueue, drain } satisfies DrainableWorker; +}); diff --git a/packages/shared/src/KeyedCoalescingWorker.ts b/packages/shared/src/KeyedCoalescingWorker.ts index 733a3b3c537f..e36859367a9c 100644 --- a/packages/shared/src/KeyedCoalescingWorker.ts +++ b/packages/shared/src/KeyedCoalescingWorker.ts @@ -14,100 +14,99 @@ interface KeyedCoalescingWorkerState { readonly activeKeys: Set; } -export const makeKeyedCoalescingWorker = (options: { +export const makeKeyedCoalescingWorker = Effect.fn( + "KeyedCoalescingWorker.makeKeyedCoalescingWorker", +)(function* (options: { readonly merge: (current: V, next: V) => V; readonly process: (key: K, value: V) => Effect.Effect; -}): Effect.Effect, never, Scope.Scope | R> => - Effect.gen(function* () { - const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), TxQueue.shutdown); - const stateRef = yield* TxRef.make>({ - latestByKey: new Map(), - queuedKeys: new Set(), - activeKeys: new Set(), - }); +}): Effect.fn.Return, never, Scope.Scope | R> { + const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), TxQueue.shutdown); + const stateRef = yield* TxRef.make>({ + latestByKey: new Map(), + queuedKeys: new Set(), + activeKeys: new Set(), + }); - const completeKey = (key: K): Effect.Effect => - TxRef.modify(stateRef, (state) => { - const activeKeys = new Set(state.activeKeys); - activeKeys.delete(key); + const completeKey = (key: K): Effect.Effect => + TxRef.modify(stateRef, (state) => { + const activeKeys = new Set(state.activeKeys); + activeKeys.delete(key); - if (state.latestByKey.has(key) && !state.queuedKeys.has(key)) { - const queuedKeys = new Set(state.queuedKeys); - queuedKeys.add(key); - return [true, { ...state, activeKeys, queuedKeys }] as const; - } + if (state.latestByKey.has(key) && !state.queuedKeys.has(key)) { + const queuedKeys = new Set(state.queuedKeys); + queuedKeys.add(key); + return [true, { ...state, activeKeys, queuedKeys }] as const; + } - return [false, { ...state, activeKeys }] as const; - }).pipe( - Effect.flatMap((shouldRequeue) => - shouldRequeue ? TxQueue.offer(queue, key) : Effect.void, - ), - Effect.tx, - Effect.asVoid, - ); + return [false, { ...state, activeKeys }] as const; + }).pipe( + Effect.flatMap((shouldRequeue) => (shouldRequeue ? TxQueue.offer(queue, key) : Effect.void)), + Effect.tx, + Effect.asVoid, + ); - yield* TxQueue.take(queue).pipe( - Effect.flatMap((key) => - TxRef.modify(stateRef, (state) => { - const queuedKeys = new Set(state.queuedKeys); - queuedKeys.delete(key); + yield* TxQueue.take(queue).pipe( + Effect.flatMap((key) => + TxRef.modify(stateRef, (state) => { + const queuedKeys = new Set(state.queuedKeys); + queuedKeys.delete(key); - const value = state.latestByKey.get(key); - if (value === undefined) { - return [null, { ...state, queuedKeys }] as const; - } + const value = state.latestByKey.get(key); + if (value === undefined) { + return [null, { ...state, queuedKeys }] as const; + } - const latestByKey = new Map(state.latestByKey); - latestByKey.delete(key); - const activeKeys = new Set(state.activeKeys); - activeKeys.add(key); + const latestByKey = new Map(state.latestByKey); + latestByKey.delete(key); + const activeKeys = new Set(state.activeKeys); + activeKeys.add(key); - return [ - { key, value } as const, - { ...state, latestByKey, queuedKeys, activeKeys }, - ] as const; - }).pipe(Effect.tx), - ), - Effect.flatMap((item) => - item === null - ? Effect.void - : options - .process(item.key, item.value) - .pipe(Effect.ignoreCause, Effect.ensuring(completeKey(item.key))), - ), - Effect.forever, - Effect.forkScoped, - ); + return [ + { key, value } as const, + { ...state, latestByKey, queuedKeys, activeKeys }, + ] as const; + }).pipe(Effect.tx), + ), + Effect.flatMap((item) => + item === null + ? Effect.void + : options + .process(item.key, item.value) + .pipe(Effect.ignoreCause, Effect.ensuring(completeKey(item.key))), + ), + Effect.forever, + Effect.forkScoped, + ); - const enqueue: KeyedCoalescingWorker["enqueue"] = (key, value) => - TxRef.modify(stateRef, (state) => { - const latestByKey = new Map(state.latestByKey); - const existing = latestByKey.get(key); - latestByKey.set(key, existing === undefined ? value : options.merge(existing, value)); + const enqueue: KeyedCoalescingWorker["enqueue"] = (key, value) => + TxRef.modify(stateRef, (state) => { + const latestByKey = new Map(state.latestByKey); + const existing = latestByKey.get(key); + latestByKey.set(key, existing === undefined ? value : options.merge(existing, value)); - if (state.queuedKeys.has(key) || state.activeKeys.has(key)) { - return [false, { ...state, latestByKey }] as const; - } + if (state.queuedKeys.has(key) || state.activeKeys.has(key)) { + return [false, { ...state, latestByKey }] as const; + } - const queuedKeys = new Set(state.queuedKeys); - queuedKeys.add(key); - return [true, { ...state, latestByKey, queuedKeys }] as const; - }).pipe( - Effect.flatMap((shouldOffer) => (shouldOffer ? TxQueue.offer(queue, key) : Effect.void)), - Effect.tx, - Effect.asVoid, - ); + const queuedKeys = new Set(state.queuedKeys); + queuedKeys.add(key); + return [true, { ...state, latestByKey, queuedKeys }] as const; + }).pipe( + Effect.flatMap((shouldOffer) => (shouldOffer ? TxQueue.offer(queue, key) : Effect.void)), + Effect.tx, + Effect.asVoid, + ); - const drainKey: KeyedCoalescingWorker["drainKey"] = (key) => - TxRef.get(stateRef).pipe( - Effect.tap((state) => - state.latestByKey.has(key) || state.queuedKeys.has(key) || state.activeKeys.has(key) - ? Effect.txRetry - : Effect.void, - ), - Effect.asVoid, - Effect.tx, - ); + const drainKey: KeyedCoalescingWorker["drainKey"] = (key) => + TxRef.get(stateRef).pipe( + Effect.tap((state) => + state.latestByKey.has(key) || state.queuedKeys.has(key) || state.activeKeys.has(key) + ? Effect.txRetry + : Effect.void, + ), + Effect.asVoid, + Effect.tx, + ); - return { enqueue, drainKey } satisfies KeyedCoalescingWorker; - }); + return { enqueue, drainKey } satisfies KeyedCoalescingWorker; +}); diff --git a/packages/shared/src/KeyedLock.ts b/packages/shared/src/KeyedLock.ts index 0411d7ef7e76..27ddcd792109 100644 --- a/packages/shared/src/KeyedLock.ts +++ b/packages/shared/src/KeyedLock.ts @@ -15,41 +15,42 @@ interface LockEntry { /** * Not reentrant: taking a key while already holding it deadlocks. */ -export const make = (): Effect.Effect> => - Effect.gen(function* () { - const locks = yield* Ref.make>(new Map()); +export const make = Effect.fn("KeyedLock.make")(function* (): Effect.fn.Return< + KeyedLock +> { + const locks = yield* Ref.make>(new Map()); - const acquire = (key: Key) => - Effect.flatMap(Semaphore.make(1), (candidate) => - Ref.modify(locks, (current) => { - const existing = current.get(key); - const semaphore = existing?.semaphore ?? candidate; - const next = new Map(current); - next.set(key, { semaphore, users: (existing?.users ?? 0) + 1 }); - return [semaphore, next] as const; - }), - ); - - const release = (key: Key) => - Ref.update(locks, (current) => { + const acquire = (key: Key) => + Effect.flatMap(Semaphore.make(1), (candidate) => + Ref.modify(locks, (current) => { const existing = current.get(key); - if (existing === undefined) return current; + const semaphore = existing?.semaphore ?? candidate; const next = new Map(current); - if (existing.users === 1) { - next.delete(key); - } else { - next.set(key, { ...existing, users: existing.users - 1 }); - } - return next; - }); + next.set(key, { semaphore, users: (existing?.users ?? 0) + 1 }); + return [semaphore, next] as const; + }), + ); + + const release = (key: Key) => + Ref.update(locks, (current) => { + const existing = current.get(key); + if (existing === undefined) return current; + const next = new Map(current); + if (existing.users === 1) { + next.delete(key); + } else { + next.set(key, { ...existing, users: existing.users - 1 }); + } + return next; + }); - return { - withLock: (key, effect) => - Effect.acquireUseRelease( - acquire(key), - (semaphore) => semaphore.withPermit(effect), - () => release(key), - ), - activeKeys: Effect.map(Ref.get(locks), (current) => Array.from(current.keys())), - } satisfies KeyedLock; - }); + return { + withLock: (key, effect) => + Effect.acquireUseRelease( + acquire(key), + (semaphore) => semaphore.withPermit(effect), + () => release(key), + ), + activeKeys: Effect.map(Ref.get(locks), (current) => Array.from(current.keys())), + } satisfies KeyedLock; +}); diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 7acb024d8590..de56d07fadb0 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -111,23 +111,24 @@ export const make = () => { }); }); - const isPortAvailableOnLoopback = (port: number): Effect.Effect => - Effect.gen(function* () { - const hasListener = yield* Effect.zipWith( - hasListenerOnHost(port, "127.0.0.1"), - hasListenerOnHost(port, "::1"), - (ipv4, ipv6) => ipv4 || ipv6, - ); - if (hasListener) { - return false; - } - - return yield* Effect.zipWith( - canListenOnHost(port, "127.0.0.1"), - canListenOnHost(port, "::1"), - (ipv4, ipv6) => ipv4 && ipv6, - ); - }); + const isPortAvailableOnLoopback = Effect.fn("Net.isPortAvailableOnLoopback")(function* ( + port: number, + ): Effect.fn.Return { + const hasListener = yield* Effect.zipWith( + hasListenerOnHost(port, "127.0.0.1"), + hasListenerOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 || ipv6, + ); + if (hasListener) { + return false; + } + + return yield* Effect.zipWith( + canListenOnHost(port, "127.0.0.1"), + canListenOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 && ipv6, + ); + }); const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => Effect.callback((resume) => { @@ -166,13 +167,12 @@ export const make = () => { isPortAvailableOnLoopback, hasListenerOnHost, reserveLoopbackPort, - findAvailablePort: (preferred) => - Effect.gen(function* () { - if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) { - return preferred; - } - return yield* reserveLoopbackPort(); - }), + findAvailablePort: Effect.fn("Net.findAvailablePort")(function* (preferred) { + if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) { + return preferred; + } + return yield* reserveLoopbackPort(); + }), } satisfies NetServiceShape; }; diff --git a/packages/shared/src/atomicWrite.test.ts b/packages/shared/src/atomicWrite.test.ts index 83d85b050d86..5249966d154d 100644 --- a/packages/shared/src/atomicWrite.test.ts +++ b/packages/shared/src/atomicWrite.test.ts @@ -277,11 +277,12 @@ it.effect("surfaces an unreadable link instead of writing over it", () => pathOrDescriptor: "/home/settings.json", }); - const result = yield* Effect.exit( - writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), - ); + const failure = yield* writeFileStringAtomically({ + filePath: "/home/settings.json", + contents: "after", + }).pipe(Effect.flip); - assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); + assert.deepStrictEqual(failure, readLinkFailure); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/packages/shared/src/devHome.ts b/packages/shared/src/devHome.ts index e35e63681b01..1bf729a3a428 100644 --- a/packages/shared/src/devHome.ts +++ b/packages/shared/src/devHome.ts @@ -33,46 +33,44 @@ const pointsAtLinkedWorktree = (gitFileContents: string, path: Path.Path): boole return segments.length >= 3 && segments.at(-2) === "worktrees"; }; -export const resolveGitWorktreePath = ( +export const resolveGitWorktreePath = Effect.fn("devHome.resolveGitWorktreePath")(function* ( cwd: string, -): Effect.Effect => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - let directory = path.resolve(cwd); - for (;;) { - const gitPath = path.join(directory, ".git"); - const info = yield* fileSystem.stat(gitPath).pipe(Effect.option); - if (Option.isSome(info)) { - if (info.value.type !== "File") { - return undefined; - } - const contents = yield* fileSystem - .readFileString(gitPath) - .pipe(Effect.orElseSucceed(() => "")); - return pointsAtLinkedWorktree(contents, path) ? directory : undefined; - } - const parent = path.dirname(directory); - if (parent === directory) { + let directory = path.resolve(cwd); + for (;;) { + const gitPath = path.join(directory, ".git"); + const info = yield* fileSystem.stat(gitPath).pipe(Effect.option); + if (Option.isSome(info)) { + if (info.value.type !== "File") { return undefined; } - directory = parent; + const contents = yield* fileSystem + .readFileString(gitPath) + .pipe(Effect.orElseSucceed(() => "")); + return pointsAtLinkedWorktree(contents, path) ? directory : undefined; + } + const parent = path.dirname(directory); + if (parent === directory) { + return undefined; } - }); + directory = parent; + } +}); /** * Deliberately does not require the directory to exist yet: falling back * because it is missing would send callers at the shared home. */ -export const resolveWorktreeT3Home = ( +export const resolveWorktreeT3Home = Effect.fn("devHome.resolveWorktreeT3Home")(function* ( cwd: string, -): Effect.Effect => - Effect.gen(function* () { - const worktreePath = yield* resolveGitWorktreePath(cwd); - if (worktreePath === undefined) { - return undefined; - } - const path = yield* Path.Path; - return path.join(worktreePath, ".t3"); - }); +): Effect.fn.Return { + const worktreePath = yield* resolveGitWorktreePath(cwd); + if (worktreePath === undefined) { + return undefined; + } + const path = yield* Path.Path; + return path.join(worktreePath, ".t3"); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 983ba0598eb9..48155c4337a4 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -511,8 +511,9 @@ const PathDirectoryListings = Context.Reference< Cache.Cache | undefined >("@t3tools/shared/shell/PathDirectoryListings", { defaultValue: () => undefined }); -export const withPathDirectoryListings = (effect: Effect.Effect) => - Effect.gen(function* () { +export const withPathDirectoryListings = + /* Directory reads are cached inside one shell operation; a span for each lookup adds noise. */ + Effect.fnUntraced(function* (effect: Effect.Effect) { const listings = yield* Cache.make({ capacity: 1024, lookup: listPathDirectory, diff --git a/packages/shared/src/symlink.ts b/packages/shared/src/symlink.ts index bc947b0596a8..7dfc7606e788 100644 --- a/packages/shared/src/symlink.ts +++ b/packages/shared/src/symlink.ts @@ -17,31 +17,32 @@ const isNotASymlink = (error: PlatformError.PlatformError) => * on a cycle, an overly long chain, or an unreadable link rather than handing * back a link that a rename would replace. */ -export const resolveSymlinkTarget = (filePath: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - let current = path.resolve(filePath); - for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { - const link = yield* fs.readLink(current).pipe( - Effect.map(Option.some), - Effect.catchIf(isNotASymlink, () => Effect.succeedNone), - ); - if (Option.isNone(link)) { - return current; - } - // A relative target is relative to where the link really lives, which - // differs from its lexical parent when that parent is itself a symlink. - const linkDirectory = yield* fs - .realPath(path.dirname(current)) - .pipe(Effect.catchIf(isNotFound, () => Effect.succeed(path.dirname(current)))); - current = path.resolve(linkDirectory, link.value); +export const resolveSymlinkTarget = Effect.fn("symlink.resolveSymlinkTarget")(function* ( + filePath: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(filePath); + for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { + const link = yield* fs.readLink(current).pipe( + Effect.map(Option.some), + Effect.catchIf(isNotASymlink, () => Effect.succeedNone), + ); + if (Option.isNone(link)) { + return current; } - return yield* PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "readLink", - description: "Too many levels of symbolic links", - pathOrDescriptor: filePath, - }); + // A relative target is relative to where the link really lives, which + // differs from its lexical parent when that parent is itself a symlink. + const linkDirectory = yield* fs + .realPath(path.dirname(current)) + .pipe(Effect.catchIf(isNotFound, () => Effect.succeed(path.dirname(current)))); + current = path.resolve(linkDirectory, link.value); + } + return yield* PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + description: "Too many levels of symbolic links", + pathOrDescriptor: filePath, }); +}); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index 4a060b23a425..d083384697aa 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -176,17 +176,19 @@ export const make = Effect.gen(function* () { ); }; - const diffScope = (input: { readonly cwd: string; readonly number: number }) => - Effect.gen(function* () { - const location = yield* locationOf(input); - if (location === null) return null; - const iterations = yield* cli.listIterations({ - cwd: input.cwd, - location, - number: input.number, - }); - return { location, iterations }; + const diffScope = Effect.fn("AzureDevOpsPullRequestProvider.diffScope")(function* (input: { + readonly cwd: string; + readonly number: number; + }) { + const location = yield* locationOf(input); + if (location === null) return null; + const iterations = yield* cli.listIterations({ + cwd: input.cwd, + location, + number: input.number, }); + return { location, iterations }; + }); const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; @@ -305,8 +307,8 @@ export const make = Effect.gen(function* () { })), ), - getChangeRequest: (input) => - Effect.gen(function* () { + getChangeRequest: Effect.fn("AzureDevOpsPullRequestProvider.getChangeRequest")( + function* (input) { const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); const location = pullRequest.location; const iterations = @@ -346,7 +348,9 @@ export const make = Effect.gen(function* () { : { autoMergeMethod: pullRequest.autoMergeMethod }), }; return detail; - }).pipe(Effect.mapError(fail("getChangeRequest"))), + }, + Effect.mapError(fail("getChangeRequest")), + ), getChangeRequestActivity: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( @@ -380,8 +384,8 @@ export const make = Effect.gen(function* () { // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so // the Code tab has nothing to scope itself to and always asks for the whole change. - getDiff: (input) => - Effect.gen(function* () { + getDiff: Effect.fn("AzureDevOpsPullRequestProvider.getDiff")( + function* (input) { const scope = yield* diffScope(input); if (scope === null) return EMPTY_DIFF_SLICE; const cursor = parseAzureDevOpsDiffCursor(input.cursor); @@ -479,10 +483,12 @@ export const make = Effect.gen(function* () { : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }), }; return slice; - }).pipe(Effect.mapError(fail("getDiff"))), + }, + Effect.mapError(fail("getDiff")), + ), - getDiffFileContents: (input) => - Effect.gen(function* () { + getDiffFileContents: Effect.fn("AzureDevOpsPullRequestProvider.getDiffFileContents")( + function* (input) { const scope = yield* diffScope(input); const iteration = scope?.iterations.at(-1); if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" }; @@ -496,15 +502,17 @@ export const make = Effect.gen(function* () { oldPath: input.oldPath, }, }); - }).pipe(Effect.mapError(fail("getDiffFileContents"))), + }, + Effect.mapError(fail("getDiffFileContents")), + ), /** * A path the change does not carry is at the empty revision, which is where a file the pull * request deletes sits. When the change was too long to follow to its end, those paths are left * out instead: reporting them as deleted would clear a file nobody has read. */ - getFileRevisions: (input) => - Effect.gen(function* () { + getFileRevisions: Effect.fn("AzureDevOpsPullRequestProvider.getFileRevisions")( + function* (input) { const revisions = new Map(); if (input.paths.length === 0) return { revisions }; const scope = yield* diffScope(input); @@ -525,7 +533,9 @@ export const make = Effect.gen(function* () { } } return { revisions }; - }).pipe(Effect.mapError(fail("getFileRevisions"))), + }, + Effect.mapError(fail("getFileRevisions")), + ), runAction: (input) => cli diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index e3f64c731f79..eaa607e3ef18 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -602,35 +602,33 @@ function originOf(value: string): string | null { } } -function responseError( +const responseError = Effect.fn("BitbucketApi.responseError")(function* ( operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, -): Effect.Effect { - return Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const retryAt = retryAtFromHeader(response.headers["retry-after"], now); - const collected = yield* collectUint8StreamText({ - stream: response.stream, - maxBytes: DEFAULT_MAX_RESPONSE_BYTES, - }).pipe( - Effect.mapError( - (cause) => - new BitbucketResponseBodyReadError({ - operation, - status: response.status, - retryAt, - cause, - }), - ), - ); - return yield* new BitbucketResponseError({ - operation, - status: response.status, - responseBodyLength: collected.text.length, - retryAt, - }); +): Effect.fn.Return { + const now = yield* Clock.currentTimeMillis; + const retryAt = retryAtFromHeader(response.headers["retry-after"], now); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation, + status: response.status, + retryAt, + cause, + }), + ), + ); + return yield* new BitbucketResponseError({ + operation, + status: response.status, + responseBodyLength: collected.text.length, + retryAt, }); -} +}); /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { @@ -1015,52 +1013,51 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), - createPullRequest: (input) => - Effect.gen(function* () { - const repository = yield* resolveRepository(input); - const description = yield* fileSystem.readFileString(input.bodyFile).pipe( - Effect.mapError( - (cause) => - new BitbucketPullRequestBodyReadError({ - cwd: input.cwd, - bodyFile: input.bodyFile, - cause, - }), - ), - ); - const sourceOwner = sourceWorkspace(input); - const body = { - title: input.title, - description, - source: { - branch: { - name: SourceControlProvider.sourceBranch(input), - }, - ...(sourceOwner - ? { - repository: { - full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, - }, - } - : {}), + createPullRequest: Effect.fn("BitbucketApi.createPullRequest")(function* (input) { + const repository = yield* resolveRepository(input); + const description = yield* fileSystem.readFileString(input.bodyFile).pipe( + Effect.mapError( + (cause) => + new BitbucketPullRequestBodyReadError({ + cwd: input.cwd, + bodyFile: input.bodyFile, + cause, + }), + ), + ); + const sourceOwner = sourceWorkspace(input); + const body = { + title: input.title, + description, + source: { + branch: { + name: SourceControlProvider.sourceBranch(input), }, - destination: { - branch: { - name: input.target?.refName ?? input.baseBranch, - }, + ...(sourceOwner + ? { + repository: { + full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, + }, + } + : {}), + }, + destination: { + branch: { + name: input.target?.refName ?? input.baseBranch, }, - }; - - yield* executeJson( - "createPullRequest", - HttpClientRequest.post( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, - ), - ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), - BitbucketPullRequestSchema, - ); - }), + }, + }; + + yield* executeJson( + "createPullRequest", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), + BitbucketPullRequestSchema, + ); + }), getDefaultBranch: (input) => resolveRepository(input).pipe( Effect.flatMap((locator) => @@ -1078,8 +1075,8 @@ export const make = Effect.gen(function* () { ), Effect.map(defaultChangeRequestTargetBranch), ), - checkoutPullRequest: (input) => - Effect.gen(function* () { + checkoutPullRequest: Effect.fn("BitbucketApi.checkoutPullRequest")( + function* (input) { const destinationRepository = yield* resolveRepository(input); const pullRequest = yield* getRawPullRequestFromRepository( destinationRepository, @@ -1129,17 +1126,20 @@ export const make = Effect.gen(function* () { remoteBranch, }); yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); - }).pipe( - Effect.mapError((cause) => - isBitbucketApiError(cause) - ? cause - : new BitbucketCheckoutError({ - cwd: input.cwd, - reference: input.reference, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isBitbucketApiError(cause) + ? cause + : new BitbucketCheckoutError({ + cwd: input.cwd, + reference: input.reference, + cause, + }), + ), ), - ), + ), }); }); diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index 957694c60ed8..ba8465e9cc70 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -306,8 +306,8 @@ export const make = Effect.gen(function* () { kind: SourceControlProviderKind.make("forgejo"), repositoryNameFromRemoteUrl, refineRepositoryIdentity, - listChangeRequests: (input) => - Effect.gen(function* () { + listChangeRequests: Effect.fn("ForgejoSourceControlProvider.listChangeRequests")( + function* (input) { const repo = yield* cli.resolveRepository(input); const source = SourceControlProvider.sourceControlRefFromInput(input); const branch = SourceControlProvider.sourceBranch(input); @@ -334,14 +334,16 @@ export const make = Effect.gen(function* () { if (items.length === 0) break; } return results.slice(0, limit); - }).pipe(mapError("listChangeRequests", input.cwd)), + }, + (effect, input) => effect.pipe(mapError("listChangeRequests", input.cwd)), + ), getChangeRequest: (input) => getPull(input).pipe( Effect.map(toForgejoChangeRequest), mapError("getChangeRequest", input.cwd), ), - createChangeRequest: (input) => - Effect.gen(function* () { + createChangeRequest: Effect.fn("ForgejoSourceControlProvider.createChangeRequest")( + function* (input) { const repo = yield* cli.resolveRepository(input); const source = SourceControlProvider.sourceControlRefFromInput(input); const owner = source?.owner ?? source?.repository?.split("/")[0]; @@ -357,16 +359,20 @@ export const make = Effect.gen(function* () { body: yield* fs.readFileString(input.bodyFile), }, }); - }).pipe(mapError("createChangeRequest", input.cwd)), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { + }, + (effect, input) => effect.pipe(mapError("createChangeRequest", input.cwd)), + ), + getRepositoryCloneUrls: Effect.fn("ForgejoSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { const repo = yield* cli.resolveRepository(input); return cloneUrls( yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema), ); - }).pipe(mapError("getRepositoryCloneUrls", input.cwd)), - createRepository: (input) => - Effect.gen(function* () { + }, + (effect, input) => effect.pipe(mapError("getRepositoryCloneUrls", input.cwd)), + ), + createRepository: Effect.fn("ForgejoSourceControlProvider.createRepository")( + function* (input) { const repo = yield* cli.resolveRepository(input); const user = yield* request( { ...input, path: "user" }, @@ -387,17 +393,21 @@ export const make = Effect.gen(function* () { RepositorySchema, ), ); - }).pipe(mapError("createRepository", input.cwd)), - getDefaultBranch: (input) => - Effect.gen(function* () { + }, + (effect, input) => effect.pipe(mapError("createRepository", input.cwd)), + ), + getDefaultBranch: Effect.fn("ForgejoSourceControlProvider.getDefaultBranch")( + function* (input) { const repo = yield* cli.resolveRepository(input); return ( (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema)) .default_branch ?? null ); - }).pipe(mapError("getDefaultBranch", input.cwd)), - checkoutChangeRequest: (input) => - Effect.gen(function* () { + }, + (effect, input) => effect.pipe(mapError("getDefaultBranch", input.cwd)), + ), + checkoutChangeRequest: Effect.fn("ForgejoSourceControlProvider.checkoutChangeRequest")( + function* (input) { const repo = yield* cli.resolveRepository(input); const pull = yield* getPull(input); if (repo.command === "fj") { @@ -468,6 +478,8 @@ export const make = Effect.gen(function* () { args: ["reset", "--keep", pull.head.sha], }); } - }).pipe(mapError("checkoutChangeRequest", input.cwd)), + }, + (effect, input) => effect.pipe(mapError("checkoutChangeRequest", input.cwd)), + ), }); }); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index efcc1b9e2d9e..41683de1767d 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -230,31 +230,22 @@ export const make = Effect.gen(function* () { Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), ); - const readPages = , unknown, never, never>>( - operation: string, - input: Pick, - path: string, - schema: S, - ) => - Effect.gen(function* () { - const items: Array = []; - let next: string | null = null; - for (let page = 0; page < MAX_PAGES; page++) { - const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); - if (next !== null) query.set("after", next); - const batch: S["Type"] = yield* read( - operation, - input, - { path: `${path}?${query}` }, - schema, - ); - items.push(...batch.items); - const previous: string | null = next; - next = batch.next; - if (next === null || next === previous || batch.items.length === 0) break; - } - return { items, next }; - }); + const readPages = Effect.fn("GitCafePullRequestProvider.readPages")(function* < + S extends Schema.Codec, unknown, never, never>, + >(operation: string, input: Pick, path: string, schema: S) { + const items: Array = []; + let next: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (next !== null) query.set("after", next); + const batch: S["Type"] = yield* read(operation, input, { path: `${path}?${query}` }, schema); + items.push(...batch.items); + const previous: string | null = next; + next = batch.next; + if (next === null || next === previous || batch.items.length === 0) break; + } + return { items, next }; + }); const readComments = (operation: string, input: PullRef) => pullPath(operation, input).pipe( diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 0e73babffc08..4eb3aec349cb 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1024,7 +1024,7 @@ export const make = Effect.gen(function* () { * `maxPages` have been read, or `until` is satisfied. `truncated` says pages remained when * reading stopped; a cursor GitHub hands back a second time stops the read as truncated too. */ - const readPages = ( + const readPages = Effect.fn("GitHubPullRequestApi.readPages")(function* ( stream: Stream.Stream, options: { readonly cwd: string; @@ -1033,33 +1033,32 @@ export const make = Effect.gen(function* () { readonly maxPages?: number; readonly until?: (pages: ReadonlyArray>) => boolean; }, - ) => - Effect.gen(function* () { - const pages: Page[] = []; - let repeated = false; - yield* stream.pipe( - // `pages` fails a cursor GitHub hands back unchanged, which would page forever. - Stream.catch((error) => - isGraphQlClientError(error) && error.reason._tag === "PaginationError" - ? Stream.sync(() => { - repeated = true; - }).pipe(Stream.drain) - : Stream.fail(error), - ), - Stream.runForEachWhile((page) => - Effect.sync(() => { - pages.push(page); - return ( - options.hasNextPage(page) && - pages.length < (options.maxPages ?? Infinity) && - options.until?.(pages) !== true - ); - }), - ), - ); - const last = pages.at(-1); - return { pages, truncated: repeated || (last !== undefined && options.hasNextPage(last)) }; - }); + ) { + const pages: Page[] = []; + let repeated = false; + yield* stream.pipe( + // `pages` fails a cursor GitHub hands back unchanged, which would page forever. + Stream.catch((error) => + isGraphQlClientError(error) && error.reason._tag === "PaginationError" + ? Stream.sync(() => { + repeated = true; + }).pipe(Stream.drain) + : Stream.fail(error), + ), + Stream.runForEachWhile((page) => + Effect.sync(() => { + pages.push(page); + return ( + options.hasNextPage(page) && + pages.length < (options.maxPages ?? Infinity) && + options.until?.(pages) !== true + ); + }), + ), + ); + const last = pages.at(-1); + return { pages, truncated: repeated || (last !== undefined && options.hasNextPage(last)) }; + }); /** * The pull request's own node id, which is what a mutation against the pull request itself is @@ -1207,84 +1206,81 @@ export const make = Effect.gen(function* () { }; const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = - (input) => - Effect.gen(function* () { - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitHubDiffCommitError({ cwd: input.cwd }); - } - const { owner, name } = parseRepositorySelector(input.repository); - const refsResponse = yield* api.rest({ - host: input.host, - operation: "getPullRequestDiffFileContents", - path: - input.commit === undefined - ? `repos/${owner}/${name}/pulls/${input.number}` - : `repos/${owner}/${name}/commits/${input.commit}`, - timeout: DIFF_TIMEOUT, + Effect.fn("GitHubPullRequestApi.getPullRequestDiffFileContents")(function* (input) { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* new GitHubDiffCommitError({ cwd: input.cwd }); + } + const { owner, name } = parseRepositorySelector(input.repository); + const refsResponse = yield* api.rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + path: + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}` + : `repos/${owner}/${name}/commits/${input.commit}`, + timeout: DIFF_TIMEOUT, + }); + const refs = decodeRevisionRefs(refsResponse.body); + const baseRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.base?.sha + : (refs.value.parents?.[0]?.sha ?? "") + : undefined; + const headRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.head?.sha + : refs.value.sha + : undefined; + const rootCommitNewFile = + input.commit !== undefined && input.changeType === "new" && baseRef === ""; + if ( + headRef === undefined || + (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || + !isCommitSha(headRef) + ) { + return yield* new GitHubDiffRevisionsUnavailableError({ + cwd: input.cwd, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), }); - const refs = decodeRevisionRefs(refsResponse.body); - const baseRef = Option.isSome(refs) - ? input.commit === undefined - ? refs.value.base?.sha - : (refs.value.parents?.[0]?.sha ?? "") - : undefined; - const headRef = Option.isSome(refs) - ? input.commit === undefined - ? refs.value.head?.sha - : refs.value.sha - : undefined; - const rootCommitNewFile = - input.commit !== undefined && input.changeType === "new" && baseRef === ""; - if ( - headRef === undefined || - (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || - !isCommitSha(headRef) - ) { - return yield* new GitHubDiffRevisionsUnavailableError({ - cwd: input.cwd, - number: input.number, - ...(input.commit === undefined ? {} : { commit: input.commit }), - }); - } + } - const readFile = (revision: string, filePath: string) => - api - .rest({ - host: input.host, - operation: "getPullRequestDiffFileContents", - accept: "application/vnd.github.raw+json", - path: `repos/${owner}/${name}/contents/${filePath - .split("/") - .map(encodeURIComponent) - .join("/")}?ref=${encodeURIComponent(revision)}`, - maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => - response.truncated || response.body.includes("\0") || response.invalidUtf8 - ? Effect.fail( - new GitHubDiffFileContentsUnavailableError({ - cwd: input.cwd, - path: filePath, - reason: response.truncated ? "oversized" : "binary", - }), - ) - : Effect.succeed(response.body), - ), - ); + const readFile = (revision: string, filePath: string) => + api + .rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + accept: "application/vnd.github.raw+json", + path: `repos/${owner}/${name}/contents/${filePath + .split("/") + .map(encodeURIComponent) + .join("/")}?ref=${encodeURIComponent(revision)}`, + maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.flatMap((response) => + response.truncated || response.body.includes("\0") || response.invalidUtf8 + ? Effect.fail( + new GitHubDiffFileContentsUnavailableError({ + cwd: input.cwd, + path: filePath, + reason: response.truncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(response.body), + ), + ); - const [oldContents, newContents] = yield* Effect.all( - [ - input.changeType === "new" - ? Effect.succeed("") - : readFile(baseRef ?? "", input.oldPath), - input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), - ], - { concurrency: 2 }, - ); - return { oldContents, newContents }; - }); + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" ? Effect.succeed("") : readFile(baseRef ?? "", input.oldPath), + input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }); /** `isRequired` is asked for on github.com only: an older Enterprise server may not know it. */ const asksRequired = (host: string) => host.toLowerCase() === "github.com"; @@ -1294,53 +1290,52 @@ export const make = Effect.gen(function* () { * its first hundred. Each page names the head it read, so a push mid-walk fails rather than * mixing two revisions' checks. */ - const readAllCheckContexts = ( + const readAllCheckContexts = Effect.fn("GitHubPullRequestApi.readAllCheckContexts")(function* ( input: Parameters[0], allowReserve: boolean, - ) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const target = { ...input, operation: "getPullRequestDetail", allowReserve }; - let headSha: string | null = null; - const { pages, truncated } = yield* readPages( - GraphQLClient.pages(client.PullRequestCheckContexts, { - variables: { owner, name, number: input.number, required: asksRequired(input.host) }, - connection: (result) => { - const page = toPullRequestCheckContextsPage(result); - if (page === null) return null; - headSha ??= page.headSha; - return { - ...page, - // Past the first page a different head is a push mid-walk. - changed: page.headSha !== headSha, - pageInfo: { hasNextPage: page.nextCursor !== null, endCursor: page.nextCursor }, - }; - }, - }).pipe( - Stream.mapEffect((page) => - page.changed - ? Effect.fail( - readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ), - ) - : Effect.succeed(page), - ), - ), - { - ...target, - maxPages: CHECK_CONTEXT_PAGES, - hasNextPage: (page) => page.nextCursor !== null, + ) { + const { owner, name } = parseRepositorySelector(input.repository); + const target = { ...input, operation: "getPullRequestDetail", allowReserve }; + let headSha: string | null = null; + const { pages, truncated } = yield* readPages( + GraphQLClient.pages(client.PullRequestCheckContexts, { + variables: { owner, name, number: input.number, required: asksRequired(input.host) }, + connection: (result) => { + const page = toPullRequestCheckContextsPage(result); + if (page === null) return null; + headSha ??= page.headSha; + return { + ...page, + // Past the first page a different head is a push mid-walk. + changed: page.headSha !== headSha, + pageInfo: { hasNextPage: page.nextCursor !== null, endCursor: page.nextCursor }, + }; }, - ).pipe(graphql(target)); - return { - headSha: pages[0]?.headSha ?? null, - contexts: pages.flatMap((page) => page.contexts), - truncated, - }; - }); + }).pipe( + Stream.mapEffect((page) => + page.changed + ? Effect.fail( + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ) + : Effect.succeed(page), + ), + ), + { + ...target, + maxPages: CHECK_CONTEXT_PAGES, + hasNextPage: (page) => page.nextCursor !== null, + }, + ).pipe(graphql(target)); + return { + headSha: pages[0]?.headSha ?? null, + contexts: pages.flatMap((page) => page.contexts), + truncated, + }; + }); const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); @@ -1390,49 +1385,48 @@ export const make = Effect.gen(function* () { const workflowApprovalReadError = (cwd: string, cause: unknown) => readError(cwd, "listWorkflowRunsRequiringApproval", cause); - const listHeadsByBranch = (input: { + const listHeadsByBranch = Effect.fn("GitHubPullRequestApi.listHeadsByBranch")(function* (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly headBranch: string; - }) => - Effect.gen(function* () { - const { owner, name } = parseRepositorySelector(input.repository); - const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => - pages.reduce((total, page) => total + page.heads.length, 0); - const target = { - ...input, - operation: "listWorkflowRunsRequiringApproval", - allowReserve: true, - }; - const { pages, truncated } = yield* readPages( - GraphQLClient.pages(client.PullRequestHeads, { - variables: { owner, name, head: input.headBranch }, - connection: (result) => { - const page = toPullRequestHeads(result); - return { - ...page, - pageInfo: { hasNextPage: page.nextCursor !== null, endCursor: page.nextCursor }, - }; - }, - }), - { - ...target, - hasNextPage: (page) => page.nextCursor !== null, - until: (pages) => countHeads(pages) > workflowApprovalLimit, + }) { + const { owner, name } = parseRepositorySelector(input.repository); + const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => + pages.reduce((total, page) => total + page.heads.length, 0); + const target = { + ...input, + operation: "listWorkflowRunsRequiringApproval", + allowReserve: true, + }; + const { pages, truncated } = yield* readPages( + GraphQLClient.pages(client.PullRequestHeads, { + variables: { owner, name, head: input.headBranch }, + connection: (result) => { + const page = toPullRequestHeads(result); + return { + ...page, + pageInfo: { hasNextPage: page.nextCursor !== null, endCursor: page.nextCursor }, + }; }, - ).pipe(graphql(target)); - return { heads: pages.flatMap((page) => page.heads), truncated }; - }); + }), + { + ...target, + hasNextPage: (page) => page.nextCursor !== null, + until: (pages) => countHeads(pages) > workflowApprovalLimit, + }, + ).pipe(graphql(target)); + return { heads: pages.flatMap((page) => page.heads), truncated }; + }); - const listActionRequiredRuns = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly headSha: string; - readonly headBranch: string; - }) => - Effect.gen(function* () { + const listActionRequiredRuns = Effect.fn("GitHubPullRequestApi.listActionRequiredRuns")( + function* (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headSha: string; + readonly headBranch: string; + }) { const known = yield* KnownWorkflowRuns; if (known !== null && known.headSha === input.headSha) { return known.runs.flatMap((run) => @@ -1464,7 +1458,8 @@ export const make = Effect.gen(function* () { if (read.rawCount < 100) break; } return runs; - }); + }, + ); const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = (input) => @@ -1590,8 +1585,10 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); - const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => - Effect.gen(function* () { + const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = Effect.fn( + "GitHubPullRequestApi.getPullRequestSummary", + )( + function* (input) { const lookup = pullRequestLookup(input); if (lookup === null) { return yield* new GitHubRepositorySelectorError({ @@ -1611,7 +1608,12 @@ export const make = Effect.gen(function* () { }); } return toPullRequestSummary(pullRequest); - }).pipe((effect) => batchedRead({ ...input, operation: "getPullRequestSummary" }, effect)); + }, + (effect, input) => + effect.pipe((effect) => + batchedRead({ ...input, operation: "getPullRequestSummary" }, effect), + ), + ); // Every watched pull request on a host in one read per pass. A pull request the batch has no // answer for gets null, and its watch reads it in full; a failed request fails every entry. @@ -1681,39 +1683,38 @@ export const make = Effect.gen(function* () { * Pages of up to a hundred until `rows` have arrived or GitHub has no more. Each page asks * for only the rows still missing, so the last one does not overshoot. */ - const collect = ( + const collect = Effect.fn("GitHubPullRequestApi.collect")(function* ( rows: number, page: ( after: string | null, rows: number, ) => Effect.Effect, - ) => - Effect.gen(function* () { - const countRows = (pages: ReadonlyArray) => - pages.reduce((total, read) => total + read.rawCount, 0); - const pages: GitHubPullRequestListPage[] = []; - const seen = new Set(); - let after: string | null = null; - let truncated = false; - while (true) { - const read: GitHubPullRequestListPage = yield* page(after, rows - countRows(pages)); - pages.push(read); - const next = read.endCursor; - if (next === null) break; - // `truncated` also covers a cursor GitHub repeated before `rows` arrived. - if (seen.has(next) || countRows(pages) >= rows) { - truncated = true; - break; - } - seen.add(next); - after = next; + ) { + const countRows = (pages: ReadonlyArray) => + pages.reduce((total, read) => total + read.rawCount, 0); + const pages: GitHubPullRequestListPage[] = []; + const seen = new Set(); + let after: string | null = null; + let truncated = false; + while (true) { + const read: GitHubPullRequestListPage = yield* page(after, rows - countRows(pages)); + pages.push(read); + const next = read.endCursor; + if (next === null) break; + // `truncated` also covers a cursor GitHub repeated before `rows` arrived. + if (seen.has(next) || countRows(pages) >= rows) { + truncated = true; + break; } - return { - items: pages.flatMap((read) => read.items), - rawCount: countRows(pages), - truncated, - }; - }); + seen.add(next); + after = next; + } + return { + items: pages.flatMap((read) => read.items), + rawCount: countRows(pages), + truncated, + }; + }); const stacks = input.host.toLowerCase() === "github.com"; const read = ( continues: boolean, @@ -1859,8 +1860,8 @@ export const make = Effect.gen(function* () { ); }, - listPullRequestStats: (input) => - Effect.gen(function* () { + listPullRequestStats: Effect.fn("GitHubPullRequestApi.listPullRequestStats")( + function* (input) { const lookups = input.changeRequests.map((changeRequest) => ({ changeRequest, lookup: pullRequestLookup(changeRequest), @@ -1888,7 +1889,12 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ); return stats.flat(); - }).pipe((effect) => batchedRead({ ...input, operation: "listPullRequestStats" }, effect)), + }, + (effect, input) => + effect.pipe((effect) => + batchedRead({ ...input, operation: "listPullRequestStats" }, effect), + ), + ), getPullRequestSummary, getPullRequestWatchFingerprint, @@ -1935,8 +1941,8 @@ export const make = Effect.gen(function* () { ); }, - getPullRequestActivity: (input) => - Effect.gen(function* () { + getPullRequestActivity: Effect.fn("GitHubPullRequestApi.getPullRequestActivity")( + function* (input) { const { owner, name } = parseRepositorySelector(input.repository); let author: PullRequestActor | null = null; let commits: ReadonlyArray = []; @@ -1981,7 +1987,8 @@ export const make = Effect.gen(function* () { ), commits, } satisfies GitHubPullRequestActivity; - }), + }, + ), getPullRequestDiff: (input) => { const filesPage = (page: number) => @@ -2057,8 +2064,8 @@ export const make = Effect.gen(function* () { ); }, - listReviewThreadComments: (input) => - Effect.gen(function* () { + listReviewThreadComments: Effect.fn("GitHubPullRequestApi.listReviewThreadComments")( + function* (input) { const { owner, name } = parseRepositorySelector(input.repository); const target = { ...input, operation: "listReviewThreadComments" }; const threadPage = ( @@ -2157,7 +2164,8 @@ export const make = Effect.gen(function* () { commits, viewer, }; - }), + }, + ), listActorAvatars: (input) => { if (input.ids.length === 0) { diff --git a/packages/source-control-github/src/server/GitHubRequestPolicy.ts b/packages/source-control-github/src/server/GitHubRequestPolicy.ts index 831ba010b9c8..a34467aaa4bb 100644 --- a/packages/source-control-github/src/server/GitHubRequestPolicy.ts +++ b/packages/source-control-github/src/server/GitHubRequestPolicy.ts @@ -260,96 +260,92 @@ export const make = Effect.gen(function* () { return { token: held.token, fingerprint: held.fingerprint }; }); - const request: GitHubRequestPolicy["Service"]["request"] = ( + // The REST/GraphQL transport span owns request and quota metadata. + const request: GitHubRequestPolicy["Service"]["request"] = Effect.fnUntraced(function* ( input: GitHubRequest, send: (headers: Readonly>) => Effect.Effect, E, R>, - ) => - Effect.gen(function* () { - const host = normalizeHost(input.host); - const context = { host, operation: input.operation }; + ) { + const host = normalizeHost(input.host); + const context = { host, operation: input.operation }; + yield* Effect.annotateCurrentSpan({ + "github.host": host, + "github.operation": input.operation, + }); + const { token, fingerprint } = yield* credential(host); + const scope = yield* SourceControlRateLimit.CredentialScope; + // A refusal pauses the whole host, whichever quota it spent: the key PullRequestService + // records its own backoff under. + const key = { provider: SourceControlProviderKind.make("github"), host }; + + // Settlement annotates the request span with status and rate-limit attributes. + const settle = Effect.fnUntraced(function* (lease: number, answer: GitHubAnswer) { + yield* quota.observe(host, answer.headers); yield* Effect.annotateCurrentSpan({ - "github.host": host, - "github.operation": input.operation, + ...(answer.status === undefined ? {} : { "http.response.status_code": answer.status }), + ...rateLimitAttributes(answer.headers), }); - const { token, fingerprint } = yield* credential(host); - const scope = yield* SourceControlRateLimit.CredentialScope; - // A refusal pauses the whole host, whichever quota it spent: the key PullRequestService - // records its own backoff under. - const key = { provider: SourceControlProviderKind.make("github"), host }; - - const settle = (lease: number, answer: GitHubAnswer) => - Effect.gen(function* () { - yield* quota.observe(host, answer.headers); + switch (answer._tag) { + case "Ok": + yield* limits.recordSuccess({ ...key, lease }); + return answer.value; + case "RateLimited": { + const retryAt = retryAtFrom(answer.headers, yield* Clock.currentTimeMillis); + yield* limits.recordRateLimit({ ...key, lease, retryAt }); yield* Effect.annotateCurrentSpan({ - ...(answer.status === undefined ? {} : { "http.response.status_code": answer.status }), - ...rateLimitAttributes(answer.headers), + "github.rate_limited": true, + ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), }); - switch (answer._tag) { - case "Ok": - yield* limits.recordSuccess({ ...key, lease }); - return answer.value; - case "RateLimited": { - const retryAt = retryAtFrom(answer.headers, yield* Clock.currentTimeMillis); - yield* limits.recordRateLimit({ ...key, lease, retryAt }); - yield* Effect.annotateCurrentSpan({ - "github.rate_limited": true, - ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), - }); - return yield* new GitHubApiRateLimitError({ - ...context, - ...(retryAt === undefined ? {} : { retryAt }), - }); - } - case "Unauthorized": - // The source may hold a newer token than the one that was refused. - yield* credentials.invalidate(host); - return yield* new GitHubApiAuthenticationError(context); - case "NotFound": - return yield* new GitHubApiNotFoundError(context); - case "Failed": - return yield* new GitHubApiResponseError({ - ...context, - status: answer.status ?? 200, - ...(answer.messages === undefined ? {} : { githubErrors: answer.messages }), - }); - } - }); + return yield* new GitHubApiRateLimitError({ + ...context, + ...(retryAt === undefined ? {} : { retryAt }), + }); + } + case "Unauthorized": + // The source may hold a newer token than the one that was refused. + yield* credentials.invalidate(host); + return yield* new GitHubApiAuthenticationError(context); + case "NotFound": + return yield* new GitHubApiNotFoundError(context); + case "Failed": + return yield* new GitHubApiResponseError({ + ...context, + status: answer.status ?? 200, + ...(answer.messages === undefined ? {} : { githubErrors: answer.messages }), + }); + } + }); - const run = Effect.gen(function* () { - const lease = yield* quota - .admit(host, input.resource, { allowReserve: input.allowReserve }) - .pipe( - Effect.andThen( - limits.check(key, input.allowReserve ? { allowPaused: true } : undefined), - ), - Effect.tapError((paused) => - Effect.annotateCurrentSpan({ - "github.paused": true, - "github.retry_at": paused.retryAt, - }), - ), - ); - // One deadline covers the headers and the body: a host that answers headers and then - // stalls must not hold a shared slot for undici's own five-minute body timeout. - const answer = yield* send({ - ...REQUEST_HEADERS, - authorization: `Bearer ${Redacted.value(token)}`, - }).pipe( - Effect.timeoutOrElse({ - duration: input.timeout ?? DEFAULT_TIMEOUT, - orElse: () => - Effect.fail( - new GitHubApiRequestError({ ...context, cause: new Cause.TimeoutError() }), - ), - }), + const run = Effect.gen(function* () { + const lease = yield* quota + .admit(host, input.resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), + Effect.tapError((paused) => + Effect.annotateCurrentSpan({ + "github.paused": true, + "github.retry_at": paused.retryAt, + }), + ), ); - return yield* settle(lease, answer); - }); - // The rate-limit scope follows the credential, so a pause on one account never blocks another. - return yield* slots - .withPermit(run) - .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + // One deadline covers the headers and the body: a host that answers headers and then + // stalls must not hold a shared slot for undici's own five-minute body timeout. + const answer = yield* send({ + ...REQUEST_HEADERS, + authorization: `Bearer ${Redacted.value(token)}`, + }).pipe( + Effect.timeoutOrElse({ + duration: input.timeout ?? DEFAULT_TIMEOUT, + orElse: () => + Effect.fail(new GitHubApiRequestError({ ...context, cause: new Cause.TimeoutError() })), + }), + ); + return yield* settle(lease, answer); }); + // The rate-limit scope follows the credential, so a pause on one account never blocks another. + return yield* slots + .withPermit(run) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + }); return GitHubRequestPolicy.of({ request, credential }); }); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 8c8aa06cd9a0..b4c2b5107fe0 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -927,8 +927,8 @@ export const make = Effect.gen(function* () { providerError("getChangeRequest", input.cwd, { reference: input.reference }), ), ), - createChangeRequest: (input) => - Effect.gen(function* () { + createChangeRequest: Effect.fn("GitHubSourceControlProvider.createChangeRequest")( + function* (input) { const locator = yield* resolveRepository(input); const body = yield* fileSystem .readFileString(input.bodyFile) @@ -953,13 +953,16 @@ export const make = Effect.gen(function* () { maintainer_can_modify: true, }, }); - }).pipe( - Effect.mapError( - providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), + }, + (effect, input) => + effect.pipe( + Effect.mapError( + providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), + ), ), - ), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { + ), + getRepositoryCloneUrls: Effect.fn("GitHubSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { const fallbackHost = (yield* resolveRepository(input).pipe( Effect.map((locator) => locator.host), Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), @@ -972,13 +975,16 @@ export const make = Effect.gen(function* () { : null); if (locator === null) return yield* failure("Repositories are named owner/name."); return repositoryCloneUrls(yield* readRepository(locator)); - }).pipe( - Effect.mapError( - providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), + }, + (effect, input) => + effect.pipe( + Effect.mapError( + providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), + ), ), - ), - createRepository: (input) => - Effect.gen(function* () { + ), + createRepository: Effect.fn("GitHubSourceControlProvider.createRepository")( + function* (input) { const locator = parseGitHubRepositorySelector( input.repository, (environment.GH_HOST ?? "github.com").toLowerCase(), @@ -1006,13 +1012,16 @@ export const make = Effect.gen(function* () { return yield* failure("GitHub returned an invalid repository.", decoded.failure); } return repositoryCloneUrls(decoded.success); - }).pipe( - Effect.mapError( - providerError("createRepository", input.cwd, { repository: input.repository }), + }, + (effect, input) => + effect.pipe( + Effect.mapError( + providerError("createRepository", input.cwd, { repository: input.repository }), + ), ), - ), - getDefaultBranch: (input) => - Effect.gen(function* () { + ), + getDefaultBranch: Effect.fn("GitHubSourceControlProvider.getDefaultBranch")( + function* (input) { const locator = yield* resolveRepository({ cwd: input.cwd, host: contextHost(input.context), @@ -1021,7 +1030,9 @@ export const make = Effect.gen(function* () { const repository = yield* readRepository(locator); const branch = repository.default_branch?.trim() ?? ""; return branch.length > 0 ? branch : null; - }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), + }, + (effect, input) => effect.pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), + ), checkoutChangeRequest: (input) => checkoutPullRequest(input).pipe( Effect.mapError( diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts index 284f38605824..5b81c198da5a 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts @@ -92,13 +92,13 @@ type DirectoryTemplateResult = | { readonly _tag: "Ambiguous" } | { readonly _tag: "Template"; readonly template: string }; -function readTemplateDirectory(input: { - readonly cwd: string; - readonly executeGit: ExecuteGit; - readonly entries: ReadonlyArray; - readonly directory: string; -}): Effect.Effect { - return Effect.gen(function* () { +const readTemplateDirectory = Effect.fn("gitHubChangeRequestTemplate.readTemplateDirectory")( + function* (input: { + readonly cwd: string; + readonly executeGit: ExecuteGit; + readonly entries: ReadonlyArray; + readonly directory: string; + }): Effect.fn.Return { const prefix = `${input.directory}/`; const candidates = input.entries.filter((entry) => { if (!entry.path.startsWith(prefix)) { @@ -122,8 +122,8 @@ function readTemplateDirectory(input: { return templates[0] ? ({ _tag: "Template", template: templates[0] } as const) : ({ _tag: "None" } as const); - }); -} + }, +); export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* ( cwd: string, diff --git a/packages/source-control-github/src/server/gitHubConditionalChecks.ts b/packages/source-control-github/src/server/gitHubConditionalChecks.ts index ebef5ba5f7b1..46b4feea6965 100644 --- a/packages/source-control-github/src/server/gitHubConditionalChecks.ts +++ b/packages/source-control-github/src/server/gitHubConditionalChecks.ts @@ -85,54 +85,56 @@ export const makeChecksRevalidator = Effect.gen(function* () { operation: "revalidateChecks", status, }); - const get = (endpoint: string, head = false) => - Effect.gen(function* () { - const previous = entry.validators.get(endpoint); - const response = yield* api - .rest({ - host: input.host, - operation: "revalidateChecks", - path: endpoint, - ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), - }) - .pipe( - Effect.catchTags({ - GitHubApiNotFoundError: () => - Effect.sync(() => { - entry.supported = false; - return null; - }), - GitHubApiResponseError: (error) => - Effect.sync(() => { - entry.supported = !UNSUPPORTED_STATUSES.has(error.status); - return null; - }), - }), - ); - if (response === null) { - entry.value = null; - return null; - } - if (response.status === 304 && previous) return previous.next; - if (response.status !== 200 || response.truncated) { - return yield* fail(response.status); - } + const get = Effect.fn("gitHubConditionalChecks.get")(function* ( + endpoint: string, + head = false, + ) { + const previous = entry.validators.get(endpoint); + const response = yield* api + .rest({ + host: input.host, + operation: "revalidateChecks", + path: endpoint, + ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), + }) + .pipe( + Effect.catchTags({ + GitHubApiNotFoundError: () => + Effect.sync(() => { + entry.supported = false; + return null; + }), + GitHubApiResponseError: (error) => + Effect.sync(() => { + entry.supported = !UNSUPPORTED_STATUSES.has(error.status); + return null; + }), + }), + ); + if (response === null) { entry.value = null; - if (head) { - const decoded = yield* decodeHead(response.body).pipe( - Effect.mapError(() => fail(response.status)), - ); - if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); - entry.head = decoded; - } - const validator = { - etag: response.headers["etag"]?.trim(), - next: /rel="next"/.test(response.headers["link"] ?? ""), - body: response.body, - }; - entry.validators.set(endpoint, validator); - return validator.next; - }); + return null; + } + if (response.status === 304 && previous) return previous.next; + if (response.status !== 200 || response.truncated) { + return yield* fail(response.status); + } + entry.value = null; + if (head) { + const decoded = yield* decodeHead(response.body).pipe( + Effect.mapError(() => fail(response.status)), + ); + if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); + entry.head = decoded; + } + const validator = { + etag: response.headers["etag"]?.trim(), + next: /rel="next"/.test(response.headers["link"] ?? ""), + body: response.body, + }; + entry.validators.set(endpoint, validator); + return validator.next; + }); const root = `repos/${input.repository}`; if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; const head = entry.head; diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index f1f6ee8944e0..427369ea510d 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -64,41 +64,40 @@ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< Effect.gen(function* () { const policy = yield* GitHubRequestPolicy.GitHubRequestPolicy; return GitHubGraphQlAuth.of({ - execute: ({ request, next }) => - Effect.gen(function* () { - const target = yield* GitHubGraphQlTarget; - const host = GitHubRequestPolicy.normalizeHost(target.host); - yield* Effect.annotateCurrentSpan({ - "github.kind": "graphql", - "github.graphql.query_hash": queryHash(request.query), - }); - return yield* policy.request( - { - host, - operation: target.operation, - resource: "graphql", - allowReserve: target.allowReserve ?? (yield* SourceControlRateLimit.Interactive), - }, - (headers) => - next({ ...request, headers: Headers.setAll(request.headers, headers) }).pipe( - Effect.map((answer) => { - const status = answer.status; - const responseHeaders = answer.headers ?? Headers.empty; - const rateLimited = GitHubRequestPolicy.isRateLimited({ - status: status ?? 200, - headers: responseHeaders, - ...(answer.errors === undefined ? {} : { graphqlErrors: answer.errors }), - }); - return rateLimited - ? { _tag: "RateLimited" as const, status, headers: responseHeaders } - : { _tag: "Ok" as const, value: answer, status, headers: responseHeaders }; - }), - Effect.catchIf(GraphQLClientError.isGraphQLClientError, (error) => - refused(error, { host, operation: target.operation }), - ), + execute: Effect.fn("GitHubGraphQlAuth.execute")(function* ({ request, next }) { + const target = yield* GitHubGraphQlTarget; + const host = GitHubRequestPolicy.normalizeHost(target.host); + yield* Effect.annotateCurrentSpan({ + "github.kind": "graphql", + "github.graphql.query_hash": queryHash(request.query), + }); + return yield* policy.request( + { + host, + operation: target.operation, + resource: "graphql", + allowReserve: target.allowReserve ?? (yield* SourceControlRateLimit.Interactive), + }, + (headers) => + next({ ...request, headers: Headers.setAll(request.headers, headers) }).pipe( + Effect.map((answer) => { + const status = answer.status; + const responseHeaders = answer.headers ?? Headers.empty; + const rateLimited = GitHubRequestPolicy.isRateLimited({ + status: status ?? 200, + headers: responseHeaders, + ...(answer.errors === undefined ? {} : { graphqlErrors: answer.errors }), + }); + return rateLimited + ? { _tag: "RateLimited" as const, status, headers: responseHeaders } + : { _tag: "Ok" as const, value: answer, status, headers: responseHeaders }; + }), + Effect.catchIf(GraphQLClientError.isGraphQLClientError, (error) => + refused(error, { host, operation: target.operation }), ), - ); - }).pipe(Effect.withSpan("GitHubGraphQlAuth.execute")), + ), + ); + }), subscribe: () => Stream.die(new Error("GitHub GraphQL has no subscriptions.")), }); }), @@ -110,80 +109,78 @@ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< * failure that never reached GitHub is a request error; one that is not about the transport * (an answer that does not decode) is the client's own. */ -const refused = ( +const refused = Effect.fn("gitHubGraphQlClient.refused")(function* ( error: GraphQLClientError.GraphQLClientError, context: { readonly host: string; readonly operation: string }, -) => - Effect.gen(function* () { - const reason = error.reason; - if (reason._tag !== "TransportError" && reason._tag !== "ResponseError") { - return yield* Effect.fail(error); - } - const status = reason.status; - if (reason._tag === "TransportError" && status === undefined) { - return yield* new GitHubRequestPolicy.GitHubApiRequestError({ ...context, cause: error }); - } - const headers = reason.headers ?? Headers.empty; - // A secondary limit is a 403 that only its body names, which the transport read. - const body = - reason._tag === "TransportError" && - HttpClientError.isHttpClientError(reason.cause) && - reason.cause.response !== undefined - ? yield* reason.cause.response.text.pipe(Effect.orElseSucceed(() => "")) - : ""; - const tag = GitHubRequestPolicy.isRateLimited({ - status: status ?? 200, - headers, - body, - ...(reason._tag === "ResponseError" ? { graphqlErrors: reason.errors } : {}), - }) - ? "RateLimited" - : status === 401 - ? "Unauthorized" - : status === 404 - ? "NotFound" - : "Failed"; - return { _tag: tag, status: status ?? 200, headers } as const; - }); +) { + const reason = error.reason; + if (reason._tag !== "TransportError" && reason._tag !== "ResponseError") { + return yield* Effect.fail(error); + } + const status = reason.status; + if (reason._tag === "TransportError" && status === undefined) { + return yield* new GitHubRequestPolicy.GitHubApiRequestError({ ...context, cause: error }); + } + const headers = reason.headers ?? Headers.empty; + // A secondary limit is a 403 that only its body names, which the transport read. + const body = + reason._tag === "TransportError" && + HttpClientError.isHttpClientError(reason.cause) && + reason.cause.response !== undefined + ? yield* reason.cause.response.text.pipe(Effect.orElseSucceed(() => "")) + : ""; + const tag = GitHubRequestPolicy.isRateLimited({ + status: status ?? 200, + headers, + body, + ...(reason._tag === "ResponseError" ? { graphqlErrors: reason.errors } : {}), + }) + ? "RateLimited" + : status === 401 + ? "Unauthorized" + : status === 404 + ? "NotFound" + : "Failed"; + return { _tag: tag, status: status ?? 200, headers } as const; +}); /** * Reads each answer whole, up to the call's byte cap, before the protocol parses it. A body cut * at the cap hides any `errors` past the cut and cannot be decoded, so it fails, which the * middleware reports as a `GitHubApiResponseError` with the answer's status. */ -const capResponse = ( +const capResponse = Effect.fn("gitHubGraphQlClient.capResponse")(function* ( effect: Effect.Effect, -) => - Effect.gen(function* () { - const response = yield* effect; - const target = yield* Effect.serviceOption(GitHubGraphQlTarget); - const maxBytes = Option.match(target, { - onNone: () => DEFAULT_MAX_RESPONSE_BYTES, - onSome: (target) => target.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - }); - const collected = yield* collectUint8StreamText({ stream: response.stream, maxBytes }).pipe( - Effect.catchIf( - (error) => error.reason._tag === "EmptyBodyError", - () => Effect.succeed({ text: "", truncated: false }), - ), - ); - if (collected.truncated) { - return yield* new HttpClientError.HttpClientError({ - reason: new HttpClientError.DecodeError({ - request: response.request, - response, - description: `The answer is longer than ${maxBytes} bytes.`, - }), - }); - } - return HttpClientResponse.fromWeb( - response.request, - new Response(collected.text === "" ? null : collected.text, { - status: response.status, - headers: response.headers, - }), - ); +) { + const response = yield* effect; + const target = yield* Effect.serviceOption(GitHubGraphQlTarget); + const maxBytes = Option.match(target, { + onNone: () => DEFAULT_MAX_RESPONSE_BYTES, + onSome: (target) => target.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, }); + const collected = yield* collectUint8StreamText({ stream: response.stream, maxBytes }).pipe( + Effect.catchIf( + (error) => error.reason._tag === "EmptyBodyError", + () => Effect.succeed({ text: "", truncated: false }), + ), + ); + if (collected.truncated) { + return yield* new HttpClientError.HttpClientError({ + reason: new HttpClientError.DecodeError({ + request: response.request, + response, + description: `The answer is longer than ${maxBytes} bytes.`, + }), + }); + } + return HttpClientResponse.fromWeb( + response.request, + new Response(collected.text === "" ? null : collected.text, { + status: response.status, + headers: response.headers, + }), + ); +}); /** * The HTTP transport, sending each request to its target host's endpoint. Resolvers batch by this diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index 2a2418c6bf47..a49d175edac8 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -1080,40 +1080,39 @@ export const make = Effect.gen(function* () { getFileRevisions: fileRevisions, - setReaction: (input) => - Effect.gen(function* () { - const subject = awardSubjectPath(input); - if (input.reacted) { - yield* api({ - cwd: input.cwd, - path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, - method: "POST", - }); - return; - } - // GitLab deletes an award by its id and takes no emoji name there, so the reader's own - // award of that name is looked up first. - const viewer = yield* viewerUsername({ cwd: input.cwd }); - const listed = yield* api({ cwd: input.cwd, path: subject }); - const own = decodeOwnAwardIdJson(listed.stdout.trim(), { - content: input.content, - viewer, - }); - if (!Result.isSuccess(own)) { - return yield* new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "setReaction", - cause: own.failure, - }); - } - if (own.success === null) return; + setReaction: Effect.fn("GitLabPullRequestCli.setReaction")(function* (input) { + const subject = awardSubjectPath(input); + if (input.reacted) { yield* api({ cwd: input.cwd, - path: `${subject}/${own.success}`, - method: "DELETE", + path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, + method: "POST", }); - }), + return; + } + // GitLab deletes an award by its id and takes no emoji name there, so the reader's own + // award of that name is looked up first. + const viewer = yield* viewerUsername({ cwd: input.cwd }); + const listed = yield* api({ cwd: input.cwd, path: subject }); + const own = decodeOwnAwardIdJson(listed.stdout.trim(), { + content: input.content, + viewer, + }); + if (!Result.isSuccess(own)) { + return yield* new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "setReaction", + cause: own.failure, + }); + } + if (own.success === null) return; + yield* api({ + cwd: input.cwd, + path: `${subject}/${own.success}`, + method: "DELETE", + }); + }), listCommits: (input) => api({ @@ -1159,56 +1158,57 @@ export const make = Effect.gen(function* () { : diffPage({ ...target, page }); }, - getMergeRequestDiffFileContents: (input) => - Effect.gen(function* () { - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); - } - const refs = yield* input.commit === undefined - ? getDiffRefs(input) - : getCommitDiffRefs({ - cwd: input.cwd, - repository: input.repository, - commit: input.commit, - allowRoot: input.changeType === "new", - }); - - const readFile = (revision: string, filePath: string) => - api({ + getMergeRequestDiffFileContents: Effect.fn( + "GitLabPullRequestCli.getMergeRequestDiffFileContents", + )(function* (input) { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); + } + const refs = yield* input.commit === undefined + ? getDiffRefs(input) + : getCommitDiffRefs({ cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( - filePath, - )}/raw?ref=${encodeURIComponent(revision)}`, - maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, - timeoutMs: DIFF_TIMEOUT_MS, - }).pipe( - Effect.flatMap((result) => - result.stdoutTruncated || - result.stdout.includes("\0") || - result.stdoutInvalidUtf8 === true - ? Effect.fail( - new GitLabDiffFileContentsUnavailableError({ - command: "glab", - cwd: input.cwd, - path: filePath, - reason: result.stdoutTruncated ? "oversized" : "binary", - }), - ) - : Effect.succeed(result.stdout), - ), - ); + repository: input.repository, + commit: input.commit, + allowRoot: input.changeType === "new", + }); - const [oldContents, newContents] = yield* Effect.all( - [ - input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), - input.changeType === "deleted" - ? Effect.succeed("") - : readFile(refs.headSha, input.newPath), - ], - { concurrency: 2 }, + const readFile = (revision: string, filePath: string) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( + filePath, + )}/raw?ref=${encodeURIComponent(revision)}`, + maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => + result.stdoutTruncated || + result.stdout.includes("\0") || + result.stdoutInvalidUtf8 === true + ? Effect.fail( + new GitLabDiffFileContentsUnavailableError({ + command: "glab", + cwd: input.cwd, + path: filePath, + reason: result.stdoutTruncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(result.stdout), + ), ); - return { oldContents, newContents }; - }), + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readFile(refs.headSha, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }), getProjectMergeCapabilities: (input) => api({ @@ -1271,8 +1271,8 @@ export const make = Effect.gen(function* () { Effect.asVoid, ), - runMergeRequestAction: (input) => - Effect.gen(function* () { + runMergeRequestAction: Effect.fn("GitLabPullRequestCli.runMergeRequestAction")( + function* (input) { // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has // no flag for is asked of GitLab directly through the same `api` passthrough the rest of // this module writes with. @@ -1298,7 +1298,8 @@ export const make = Effect.gen(function* () { flags.push("--sha", refs.headSha); } yield* executeMr(subcommand!, ...flags); - }), + }, + ), updateMergeRequest: (input) => api({ @@ -1336,55 +1337,54 @@ export const make = Effect.gen(function* () { listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), - submitReview: (input) => - Effect.gen(function* () { - const project = projectPath(input.repository); - const mergeRequest = `projects/${project}/merge_requests/${input.number}`; - // GitLab has no pending review to attach comments to, so a review is replayed as the - // requests it is made of: the line comments, then the summary, then the verdict. A - // failure part-way therefore leaves what was already posted in place, which is why - // the verdict goes last — a half-sent review is never an approval. - if (input.comments.length > 0) { - const refs = yield* getDiffRefs(input); - yield* Effect.forEach( - input.comments, - (comment) => - api({ - cwd: input.cwd, - path: `${mergeRequest}/discussions`, - method: "POST", - stdin: JSON.stringify({ - body: comment.body, - position: { - base_sha: refs.baseSha, - head_sha: refs.headSha, - start_sha: refs.startSha, - position_type: "text", - // Both paths are sent because GitLab resolves a position against both - // sides of the diff. They differ only for a renamed file, which is why the - // draft carries the name the file had before the change. - old_path: comment.oldPath ?? comment.path, - new_path: comment.path, - ...gitLabReviewPositionLines(comment.position), - }, - }), + submitReview: Effect.fn("GitLabPullRequestCli.submitReview")(function* (input) { + const project = projectPath(input.repository); + const mergeRequest = `projects/${project}/merge_requests/${input.number}`; + // GitLab has no pending review to attach comments to, so a review is replayed as the + // requests it is made of: the line comments, then the summary, then the verdict. A + // failure part-way therefore leaves what was already posted in place, which is why + // the verdict goes last — a half-sent review is never an approval. + if (input.comments.length > 0) { + const refs = yield* getDiffRefs(input); + yield* Effect.forEach( + input.comments, + (comment) => + api({ + cwd: input.cwd, + path: `${mergeRequest}/discussions`, + method: "POST", + stdin: JSON.stringify({ + body: comment.body, + position: { + base_sha: refs.baseSha, + head_sha: refs.headSha, + start_sha: refs.startSha, + position_type: "text", + // Both paths are sent because GitLab resolves a position against both + // sides of the diff. They differ only for a renamed file, which is why the + // draft carries the name the file had before the change. + old_path: comment.oldPath ?? comment.path, + new_path: comment.path, + ...gitLabReviewPositionLines(comment.position), + }, }), - { discard: true }, - ); - } - if (input.body.trim().length > 0) { - yield* api({ - cwd: input.cwd, - path: `${mergeRequest}/notes`, - method: "POST", - // @effect-diagnostics-next-line preferSchemaOverJson:off - stdin: JSON.stringify({ body: input.body }), - }); - } - if (input.verdict === "approve") { - yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); - } - }), + }), + { discard: true }, + ); + } + if (input.body.trim().length > 0) { + yield* api({ + cwd: input.cwd, + path: `${mergeRequest}/notes`, + method: "POST", + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdin: JSON.stringify({ body: input.body }), + }); + } + if (input.verdict === "approve") { + yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); + } + }), replyToDiscussion: (input) => api({ diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 4da4b8f5b777..4e8606e252a3 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -210,63 +210,65 @@ export const parseTailscaleStatus = ( }), ); -const readTailscaleOutput = (subcommand: "status" | "debug", args: readonly string[]) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcess.Platform; - const executable = tailscaleCommandForPlatform(hostPlatform); - const commandContext = { - executable, - subcommand, - argumentCount: args.length, - }; - return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(executable, [...args])).pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - // Spawning can also fail as a defect rather than a typed error - a - // non-directory entry on PATH makes node throw ENOTDIR synchronously. - // `mapError` never sees that, so it would escape as an uncaught error. - Effect.catchDefect((cause) => - Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), - ); - if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ - ...commandContext, - exitCode, - stdoutLength: stdout.length, - stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), - }); - } - return stdout; - }).pipe( - Effect.scoped, - Effect.timeout(TAILSCALE_STATUS_TIMEOUT), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.fail( - new TailscaleCommandTimeoutError({ - ...commandContext, - timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), - cause, - }), - ), - }), +const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* ( + subcommand: "status" | "debug", + args: readonly string[], +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcess.Platform; + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand, + argumentCount: args.length, + }; + return yield* Effect.gen(function* () { + const child = yield* spawner.spawn(ChildProcess.make(executable, [...args])).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + // Spawning can also fail as a defect rather than a typed error - a + // non-directory entry on PATH makes node throw ENOTDIR synchronously. + // `mapError` never sees that, so it would escape as an uncaught error. + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); - }); + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + ...(stderrDiagnosticOf(stderr) !== undefined + ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } + : {}), + }); + } + return stdout; + }).pipe( + Effect.scoped, + Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + cause, + }), + ), + }), + ); +}); export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe( Effect.flatMap(parseTailscaleStatus), @@ -308,58 +310,57 @@ export function buildTailscaleHttpsBaseUrl(input: { return url.toString(); } -const runTailscaleCommand = ( +const runTailscaleCommand = Effect.fn("tailscale.runTailscaleCommand")(function* ( args: readonly string[], timeoutInput: Duration.Input, -): Effect.Effect => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcess.Platform; - const executable = tailscaleCommandForPlatform(hostPlatform); - const commandContext = { - executable, - subcommand: "serve" as const, - argumentCount: args.length, - }; - const timeout = Duration.fromInputUnsafe(timeoutInput); - return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - Effect.catchDefect((cause) => - Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), - ), - ); - const [stderr, exitCode] = yield* Effect.all( - [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), - ); - if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ - ...commandContext, - exitCode, - stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), - }); - } - }).pipe( - Effect.scoped, - Effect.timeout(timeout), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.fail( - new TailscaleCommandTimeoutError({ - ...commandContext, - timeoutMs: Duration.toMillis(timeout), - cause, - }), - ), - }), +): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcess.Platform; + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "serve" as const, + argumentCount: args.length, + }; + const timeout = Duration.fromInputUnsafe(timeoutInput); + return yield* Effect.gen(function* () { + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), + ), ); - }); + const [stderr, exitCode] = yield* Effect.all( + [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), + ); + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stderrLength: stderr.length, + ...(stderrDiagnosticOf(stderr) !== undefined + ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } + : {}), + }); + } + }).pipe( + Effect.scoped, + Effect.timeout(timeout), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(timeout), + cause, + }), + ), + }), + ); +}); export const ensureTailscaleServe = (input: { readonly localPort: number; @@ -372,24 +373,20 @@ export const ensureTailscaleServe = (input: { return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); }; -export const disableTailscaleServe = ( +export const disableTailscaleServe = Effect.fn("tailscale.disableTailscaleServe")(function* ( input: { readonly servePort?: number; } = {}, -): Effect.Effect => - Effect.gen(function* () { - const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - return yield* runTailscaleCommand( - ["serve", `--https=${servePort}`, "off"], - TAILSCALE_SERVE_TIMEOUT, - ); - }); +): Effect.fn.Return { + const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; + return yield* runTailscaleCommand( + ["serve", `--https=${servePort}`, "off"], + TAILSCALE_SERVE_TIMEOUT, + ); +}); -export const probeTailscaleHttpsEndpoint = (input: { - readonly baseUrl: string; - readonly timeout?: Duration.Input; -}): Effect.Effect => - Effect.gen(function* () { +export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")( + function* (input: { readonly baseUrl: string; readonly timeout?: Duration.Input }) { const client = yield* HttpClient.HttpClient; const response = yield* Effect.gen(function* () { const url = new URL("/.well-known/t3/environment", input.baseUrl); @@ -401,4 +398,6 @@ export const probeTailscaleHttpsEndpoint = (input: { onNone: () => false, onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300, }); - }).pipe(Effect.orElseSucceed(() => false)); + }, + Effect.orElseSucceed(() => false), +); From 0878d3f4d4636b277bab78034b7cf6782669361c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:30:58 -0700 Subject: [PATCH 003/458] refactor(provider): trace provider runtime operations (#21991) --- .../effect-codex-app-server/src/protocol.ts | 45 +- .../src/server/AcpRegistrySupport.ts | 28 +- .../provider-acp-registry/src/server/auth.ts | 179 ++-- .../src/server/driver.ts | 805 +++++++++--------- .../src/server/ProviderLatestVersions.ts | 57 +- .../src/server/managedProvider.ts | 8 +- .../src/server/providerAuthFlow.ts | 222 ++--- .../provider-core/src/server/snapshotProbe.ts | 48 +- .../src/server/snapshotSettings.ts | 24 +- .../src/server/subagentProjection.ts | 138 +-- 10 files changed, 779 insertions(+), 775 deletions(-) diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 4a32973a988b..c1f6cb1f1b6b 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -217,8 +217,9 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa ] as const; }).pipe(Effect.flatten); - const offerOutgoing = (message: Record) => - Effect.gen(function* () { + const offerOutgoing = + /* Every protocol write reaches this helper; request spans cover the operation. */ + Effect.fnUntraced(function* (message: Record) { const failure = yield* Ref.get(terminationFailure); if (Option.isSome(failure)) return yield* failure.value; @@ -447,25 +448,27 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); - const request = (method: string, payload?: unknown) => - Effect.gen(function* () { - const requestId = yield* Ref.modify( - nextRequestId, - (current) => [current, current + 1] as const, - ); - const deferred = yield* Deferred.make(); - yield* Ref.update(pending, (current) => - new Map(current).set(String(requestId), { deferred, method }), - ); - yield* offerOutgoing({ - id: requestId, - method, - ...(payload !== undefined ? { params: payload } : {}), - }).pipe(Effect.tapError(() => removePending(String(requestId)))); - return yield* Deferred.await(deferred).pipe( - Effect.onInterrupt(() => removePending(String(requestId))), - ); - }); + const request = Effect.fn("CodexAppServerProtocol.request")(function* ( + method: string, + payload?: unknown, + ) { + const requestId = yield* Ref.modify( + nextRequestId, + (current) => [current, current + 1] as const, + ); + const deferred = yield* Deferred.make(); + yield* Ref.update(pending, (current) => + new Map(current).set(String(requestId), { deferred, method }), + ); + yield* offerOutgoing({ + id: requestId, + method, + ...(payload !== undefined ? { params: payload } : {}), + }).pipe(Effect.tapError(() => removePending(String(requestId)))); + return yield* Deferred.await(deferred).pipe( + Effect.onInterrupt(() => removePending(String(requestId))), + ); + }); const notify = (method: string, payload?: unknown) => offerOutgoing({ diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index 519c4f45df86..28e05da3f55f 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -1589,8 +1589,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct return distribution; }); - const search: AcpRegistryCatalog["Service"]["search"] = (input) => - Effect.gen(function* () { + const search: AcpRegistryCatalog["Service"]["search"] = Effect.fn("AcpRegistryCatalog.search")( + function* (input) { const registry = yield* refreshRegistry(); const ranked = registry.agents.flatMap((agent) => { const distribution = resolveAcpRegistryDistribution({ @@ -1632,10 +1632,11 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct : "registry", })), } satisfies AcpRegistrySearchResult; - }); + }, + ); - const prepare: AcpRegistryCatalog["Service"]["prepare"] = (input) => - Effect.gen(function* () { + const prepare: AcpRegistryCatalog["Service"]["prepare"] = Effect.fn("AcpRegistryCatalog.prepare")( + function* (input) { const registry = yield* refreshRegistry(); const agent = yield* findAgent(registry, input.agentId); const distribution = yield* compatibleDistribution(agent, "auto"); @@ -1658,7 +1659,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct distribution: distribution.kind, prepared: true, } satisfies AcpRegistryPrepareResult; - }); + }, + ); const validateLocalExecutable = (command: string) => platform === "win32" && /\.(?:cmd|bat)$/iu.test(command) @@ -1671,8 +1673,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct ) : Effect.void; - const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => - Effect.gen(function* () { + const inspect: AcpRegistryCatalog["Service"]["inspect"] = Effect.fn("AcpRegistryCatalog.inspect")( + function* (settings, environment) { if (settings.source === "local") { const command = settings.commandPath.trim(); if (!command) return { status: "unconfigured" } as const; @@ -1782,10 +1784,11 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct distribution: distribution.kind, runner, } as const); - }); + }, + ); - const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => - Effect.gen(function* () { + const resolve: AcpRegistryCatalog["Service"]["resolve"] = Effect.fn("AcpRegistryCatalog.resolve")( + function* (settings, cwd, environment) { if (settings.source === "local") { const executable = settings.commandPath.trim(); if (!executable) { @@ -1875,7 +1878,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct env: spawnEnvironment, }, } satisfies ResolvedAcpRegistryAgent; - }); + }, + ); const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => hostSettings diff --git a/packages/provider-acp-registry/src/server/auth.ts b/packages/provider-acp-registry/src/server/auth.ts index ac87bfc1b0c4..af82212d3eec 100644 --- a/packages/provider-acp-registry/src/server/auth.ts +++ b/packages/provider-acp-registry/src/server/auth.ts @@ -227,109 +227,104 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o return yield* failure("start", "The provider login command did not finish successfully."); }); - const authenticate = (methodId: string, context: ProviderAuthFlow.ProviderAuthFlowContext) => - Effect.gen(function* () { - if (!options.settings.enabled) - return yield* failure("start", "Enable this provider before signing in."); - const login = Effect.scoped( - Effect.gen(function* () { - const resolved = yield* resolve; - const runtimeScope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(runtimeScope, Exit.void)); - const runtime = yield* makeRuntime(resolved.spawn).pipe( - Effect.provideService(Scope.Scope, runtimeScope), - ); - yield* runtime.handleElicitation((request) => - Effect.gen(function* () { - if ( - request.mode !== "url" || - !("url" in request) || - !("elicitationId" in request) || - typeof request.url !== "string" || - typeof request.elicitationId !== "string" - ) - return { action: "decline" } as const; - const url = normalizeAcpRegistryWebUrl(request.url); - const id = request.elicitationId.trim(); - if (!url || !id || id.length > 128) return { action: "decline" } as const; - const consent = yield* Deferred.make(); - yield* context.setInteraction( - { type: "browser", id, url, requiresConsent: true }, - (response) => - response.type === "browser" - ? Deferred.succeed(consent, response.action === "accept").pipe(Effect.asVoid) - : Effect.void, - ); - return (yield* Deferred.await(consent)) - ? ({ action: "accept" } as const) - : ({ action: "decline" } as const); - }), + const authenticate = Effect.fn("AcpRegistryAuth.authenticate")(function* ( + methodId: string, + context: ProviderAuthFlow.ProviderAuthFlowContext, + ) { + if (!options.settings.enabled) + return yield* failure("start", "Enable this provider before signing in."); + const login = Effect.scoped( + Effect.gen(function* () { + const resolved = yield* resolve; + const runtimeScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(runtimeScope, Exit.void)); + const runtime = yield* makeRuntime(resolved.spawn).pipe( + Effect.provideService(Scope.Scope, runtimeScope), + ); + yield* runtime.handleElicitation((request) => + Effect.gen(function* () { + if ( + request.mode !== "url" || + !("url" in request) || + !("elicitationId" in request) || + typeof request.url !== "string" || + typeof request.elicitationId !== "string" + ) + return { action: "decline" } as const; + const url = normalizeAcpRegistryWebUrl(request.url); + const id = request.elicitationId.trim(); + if (!url || !id || id.length > 128) return { action: "decline" } as const; + const consent = yield* Deferred.make(); + yield* context.setInteraction( + { type: "browser", id, url, requiresConsent: true }, + (response) => + response.type === "browser" + ? Deferred.succeed(consent, response.action === "accept").pipe(Effect.asVoid) + : Effect.void, + ); + return (yield* Deferred.await(consent)) + ? ({ action: "accept" } as const) + : ({ action: "decline" } as const); + }), + ); + const initialized = yield* runtime + .initialize() + .pipe( + Effect.mapError((cause) => + failure("start", "Could not initialize the selected ACP agent.", cause), + ), ); - const initialized = yield* runtime - .initialize() + const method = initialized.authMethods?.find((method) => method.id === methodId); + if (!method) + return yield* failure("start", "The agent no longer advertises this sign-in method."); + if (method.type === "terminal") { + yield* Scope.close(runtimeScope, Exit.void); + yield* runTerminal(resolved, method, context).pipe(Effect.scoped); + // Terminal login is a separate invocation. Reconnect after it exits so + // the ACP process reads the credentials its login command persisted. + const verifiedRuntime = yield* makeRuntime(resolved.spawn); + yield* context.verifying; + yield* verifiedRuntime + .start() .pipe( Effect.mapError((cause) => - failure("start", "Could not initialize the selected ACP agent.", cause), + failure( + "verify", + "The provider could not create a session after terminal sign-in.", + cause, + ), ), ); - const method = initialized.authMethods?.find((method) => method.id === methodId); - if (!method) - return yield* failure("start", "The agent no longer advertises this sign-in method."); - if (method.type === "terminal") { - yield* Scope.close(runtimeScope, Exit.void); - yield* runTerminal(resolved, method, context).pipe(Effect.scoped); - // Terminal login is a separate invocation. Reconnect after it exits so - // the ACP process reads the credentials its login command persisted. - const verifiedRuntime = yield* makeRuntime(resolved.spawn); - yield* context.verifying; - yield* verifiedRuntime - .start() - .pipe( - Effect.mapError((cause) => - failure( - "verify", - "The provider could not create a session after terminal sign-in.", - cause, - ), - ), - ); - } else { - if (method.type === undefined || method.type === "agent") { - if (!runtime.authenticate) - return yield* failure( - "start", - "This runtime does not support explicit ACP sign-in.", - ); - yield* runtime - .authenticate(methodId) - .pipe( - Effect.mapError((cause) => - failure("start", "The ACP agent could not complete sign-in.", cause), - ), - ); - } - // Legacy env_var methods use the secret-backed environment fields in - // provider settings. Never send their ID to authenticate. - yield* context.verifying; + } else { + if (method.type === undefined || method.type === "agent") { + if (!runtime.authenticate) + return yield* failure("start", "This runtime does not support explicit ACP sign-in."); yield* runtime - .start() + .authenticate(methodId) .pipe( Effect.mapError((cause) => - failure( - "verify", - "The provider could not create a session after sign-in.", - cause, - ), + failure("start", "The ACP agent could not complete sign-in.", cause), ), ); } - }), - ); - yield* Option.isSome(coordinator) - ? coordinator.value.withForegroundStartup(agentKey, login) - : login; - yield* options.onChanged(true); - }); + // Legacy env_var methods use the secret-backed environment fields in + // provider settings. Never send their ID to authenticate. + yield* context.verifying; + yield* runtime + .start() + .pipe( + Effect.mapError((cause) => + failure("verify", "The provider could not create a session after sign-in.", cause), + ), + ); + } + }), + ); + yield* Option.isSome(coordinator) + ? coordinator.value.withForegroundStartup(agentKey, login) + : login; + yield* options.onChanged(true); + }); const logout = Effect.scoped( Effect.gen(function* () { const resolved = yield* resolve; diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index dce555f0c5dc..1f0f7401e204 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -464,438 +464,439 @@ export const AcpRegistryDriver: ProviderDriver decodeSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; - const runtimeCoordinator = yield* Effect.serviceOption( - AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, - ); - if (Option.isSome(runtimeCoordinator)) { - yield* runtimeCoordinator.value.clearAvailableCommands(instanceId); - yield* runtimeCoordinator.value.clearLiveConfiguration(instanceId); - yield* Effect.addFinalizer(() => - runtimeCoordinator.value - .clearAvailableCommands(instanceId) - .pipe(Effect.andThen(runtimeCoordinator.value.clearLiveConfiguration(instanceId))), - ); - } - const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostEnvironment = yield* HostProcess.Environment; - const host = yield* ProviderHost.ProviderHost; - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const identity = { - instanceId, - displayName, - accentColor, - continuationKey: continuationIdentity.continuationKey, - }; - const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings; - const processEnvironment = yield* mergeProviderInstanceEnvironment( - environment, - hostEnvironment, + create: Effect.fn("AcpRegistryDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; + const runtimeCoordinator = yield* Effect.serviceOption( + AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, + ); + if (Option.isSome(runtimeCoordinator)) { + yield* runtimeCoordinator.value.clearAvailableCommands(instanceId); + yield* runtimeCoordinator.value.clearLiveConfiguration(instanceId); + yield* Effect.addFinalizer(() => + runtimeCoordinator.value + .clearAvailableCommands(instanceId) + .pipe(Effect.andThen(runtimeCoordinator.value.clearLiveConfiguration(instanceId))), ); - const orchestrationAdapter = yield* AcpRegistryAdapterV2Driver.create({ + } + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostEnvironment = yield* HostProcess.Environment; + const host = yield* ProviderHost.ProviderHost; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const identity = { + instanceId, + displayName, + accentColor, + continuationKey: continuationIdentity.continuationKey, + }; + const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings; + const processEnvironment = yield* mergeProviderInstanceEnvironment( + environment, + hostEnvironment, + ); + const orchestrationAdapter = yield* AcpRegistryAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build ACP Registry orchestration adapter.", + cause, + }), + ), + ); + const readinessInput = { + ...identity, + settings: effectiveConfig, + environment: processEnvironment, + }; + const confirmedAuthentication = + yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({ + cacheDir: host.paths.providerStatusCacheDir, instanceId, - displayName, - accentColor, + settings: effectiveConfig, environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build ACP Registry orchestration adapter.", - cause, + processEnvironment, + }); + const withLiveRuntimeState = Effect.fn("AcpRegistryDriver.withLiveRuntimeState")(function* ( + input: ServerProvider, + ) { + if (input.auth.status === "unauthenticated") yield* confirmedAuthentication.set(false); + const provider = + input.enabled && + input.installed && + input.auth.status === "unknown" && + (yield* confirmedAuthentication.get) + ? { ...input, auth: { ...input.auth, status: "authenticated" as const } } + : input; + return yield* Option.isNone(runtimeCoordinator) + ? Effect.succeed(provider) + : Effect.all({ + commands: runtimeCoordinator.value.getAvailableCommands(instanceId), + configuration: runtimeCoordinator.value.getLiveConfiguration(instanceId), + authAction: runtimeCoordinator.value.getUrlAuthAction(instanceId), + }).pipe( + Effect.map(({ commands, configuration, authAction }) => { + const withCommands = applyAcpRegistryAvailableCommands(provider, commands); + const withConfiguration = Option.match(configuration, { + onNone: () => withCommands, + onSome: (liveConfiguration) => + applyAcpRegistryLiveConfiguration( + withCommands, + liveConfiguration, + effectiveConfig.customModels, + ), + }); + return applyAcpRegistryUrlAuthAction(withConfiguration, authAction); }), - ), - ); - const readinessInput = { - ...identity, - settings: effectiveConfig, - environment: processEnvironment, - }; - const confirmedAuthentication = - yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({ - cacheDir: host.paths.providerStatusCacheDir, - instanceId, - settings: effectiveConfig, - environment, - processEnvironment, - }); - const withLiveRuntimeState = (input: ServerProvider) => - Effect.gen(function* () { - if (input.auth.status === "unauthenticated") yield* confirmedAuthentication.set(false); - const provider = - input.enabled && - input.installed && - input.auth.status === "unknown" && - (yield* confirmedAuthentication.get) - ? { ...input, auth: { ...input.auth, status: "authenticated" as const } } - : input; - return yield* Option.isNone(runtimeCoordinator) - ? Effect.succeed(provider) - : Effect.all({ - commands: runtimeCoordinator.value.getAvailableCommands(instanceId), - configuration: runtimeCoordinator.value.getLiveConfiguration(instanceId), - authAction: runtimeCoordinator.value.getUrlAuthAction(instanceId), - }).pipe( - Effect.map(({ commands, configuration, authAction }) => { - const withCommands = applyAcpRegistryAvailableCommands(provider, commands); - const withConfiguration = Option.match(configuration, { - onNone: () => withCommands, - onSome: (liveConfiguration) => - applyAcpRegistryLiveConfiguration( - withCommands, - liveConfiguration, - effectiveConfig.customModels, - ), - }); - return applyAcpRegistryUrlAuthAction(withConfiguration, authAction); - }), - ); - }); - const checkProvider = checkAcpRegistryProviderReadiness(readinessInput).pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.flatMap(withLiveRuntimeState), - ); - const enrichProvider = checkAcpRegistryProviderStatus( - { - ...readinessInput, - cwd: host.paths.cwd, - }, - probeAcpRegistryConfiguration, - ).pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Crypto.Crypto, crypto), - ); - const enrichmentCache = yield* Ref.make<{ - readonly generation: number; - readonly entry: { - readonly provider: ServerProvider; - readonly expiresAt: number; - } | null; - }>({ generation: 0, entry: null }); - const liveSnapshotSemaphore = yield* Semaphore.make(1); - const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({ - generation: current.generation + 1, - entry: null, - })); - const enrichProviderCached = (baseSnapshot: ServerProvider) => - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const cacheState = yield* Ref.get(enrichmentCache); - const cached = cacheState.entry; - if ( - cached !== null && - cached.expiresAt > now && - cached.provider.version === baseSnapshot.version - ) { - return { - provider: { ...cached.provider, checkedAt: baseSnapshot.checkedAt }, - generation: cacheState.generation, - }; - } - const enriched = yield* enrichProvider; - if (enriched.status === "ready") { - yield* Ref.update(enrichmentCache, (current) => - current.generation === cacheState.generation - ? { - ...current, - entry: { - provider: enriched, - expiresAt: now + PROBE_SUCCESS_TTL_MS, - }, - } - : current, - ); - } - return { provider: enriched, generation: cacheState.generation }; - }); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider< - ProviderSnapshotSettings - >({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE), - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: () => checkProvider, - checkProvider, - enrichSnapshot: ({ snapshot, getSnapshot, publishSnapshot }) => { - if (!snapshot.installed) return Effect.void; - const publishEnrichment = ( - Option.isSome(runtimeCoordinator) - ? runtimeCoordinator.value.runBackgroundProbe( - effectiveConfig.source === "local" - ? `local:${instanceId}` - : effectiveConfig.agentId, - enrichProviderCached(snapshot), - ) - : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) - ).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.void, - onSome: ({ provider, generation }) => - liveSnapshotSemaphore.withPermit( - Ref.get(enrichmentCache).pipe( - Effect.flatMap((current) => - current.generation === generation - ? withLiveRuntimeState(provider).pipe(Effect.flatMap(publishSnapshot)) - : Effect.void, - ), + ); + }); + const checkProvider = checkAcpRegistryProviderReadiness(readinessInput).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), + Effect.flatMap(withLiveRuntimeState), + ); + const enrichProvider = checkAcpRegistryProviderStatus( + { + ...readinessInput, + cwd: host.paths.cwd, + }, + probeAcpRegistryConfiguration, + ).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Crypto.Crypto, crypto), + ); + const enrichmentCache = yield* Ref.make<{ + readonly generation: number; + readonly entry: { + readonly provider: ServerProvider; + readonly expiresAt: number; + } | null; + }>({ generation: 0, entry: null }); + const liveSnapshotSemaphore = yield* Semaphore.make(1); + const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({ + generation: current.generation + 1, + entry: null, + })); + const enrichProviderCached = Effect.fn("AcpRegistryDriver.enrichProviderCached")(function* ( + baseSnapshot: ServerProvider, + ) { + const now = yield* Clock.currentTimeMillis; + const cacheState = yield* Ref.get(enrichmentCache); + const cached = cacheState.entry; + if ( + cached !== null && + cached.expiresAt > now && + cached.provider.version === baseSnapshot.version + ) { + return { + provider: { ...cached.provider, checkedAt: baseSnapshot.checkedAt }, + generation: cacheState.generation, + }; + } + const enriched = yield* enrichProvider; + if (enriched.status === "ready") { + yield* Ref.update(enrichmentCache, (current) => + current.generation === cacheState.generation + ? { + ...current, + entry: { + provider: enriched, + expiresAt: now + PROBE_SUCCESS_TTL_MS, + }, + } + : current, + ); + } + return { provider: enriched, generation: cacheState.generation }; + }); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: () => checkProvider, + checkProvider, + enrichSnapshot: ({ snapshot, getSnapshot, publishSnapshot }) => { + if (!snapshot.installed) return Effect.void; + const publishEnrichment = ( + Option.isSome(runtimeCoordinator) + ? runtimeCoordinator.value.runBackgroundProbe( + effectiveConfig.source === "local" + ? `local:${instanceId}` + : effectiveConfig.agentId, + enrichProviderCached(snapshot), + ) + : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) + ).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: ({ provider, generation }) => + liveSnapshotSemaphore.withPermit( + Ref.get(enrichmentCache).pipe( + Effect.flatMap((current) => + current.generation === generation + ? withLiveRuntimeState(provider).pipe(Effect.flatMap(publishSnapshot)) + : Effect.void, ), ), - }), - ), - ); - if (Option.isNone(runtimeCoordinator)) return publishEnrichment; + ), + }), + ), + ); + if (Option.isNone(runtimeCoordinator)) return publishEnrichment; - const publishLiveCommands = runtimeCoordinator.value.watchAvailableCommands( - instanceId, - ({ slashCommands, skills }) => - liveSnapshotSemaphore.withPermit( - getSnapshot.pipe( - Effect.flatMap((current) => - publishSnapshot({ - ...current, - slashCommands, - skills, - }), - ), + const publishLiveCommands = runtimeCoordinator.value.watchAvailableCommands( + instanceId, + ({ slashCommands, skills }) => + liveSnapshotSemaphore.withPermit( + getSnapshot.pipe( + Effect.flatMap((current) => + publishSnapshot({ + ...current, + slashCommands, + skills, + }), ), ), - ); - const publishLiveConfiguration = runtimeCoordinator.value.watchLiveConfiguration( - instanceId, - (configuration) => - liveSnapshotSemaphore.withPermit( - getSnapshot.pipe( - Effect.flatMap((current) => - publishSnapshot( - applyAcpRegistryLiveConfiguration( - current, - configuration, - effectiveConfig.customModels, - ), + ), + ); + const publishLiveConfiguration = runtimeCoordinator.value.watchLiveConfiguration( + instanceId, + (configuration) => + liveSnapshotSemaphore.withPermit( + getSnapshot.pipe( + Effect.flatMap((current) => + publishSnapshot( + applyAcpRegistryLiveConfiguration( + current, + configuration, + effectiveConfig.customModels, ), ), ), ), - ); - const publishUrlAuthAction = runtimeCoordinator.value.watchUrlAuthAction( - instanceId, - (action) => - liveSnapshotSemaphore.withPermit( - getSnapshot.pipe( - Effect.flatMap((current) => - publishSnapshot( - applyAcpRegistryUrlAuthAction(current, Option.fromNullishOr(action)), - ), + ), + ); + const publishUrlAuthAction = runtimeCoordinator.value.watchUrlAuthAction( + instanceId, + (action) => + liveSnapshotSemaphore.withPermit( + getSnapshot.pipe( + Effect.flatMap((current) => + publishSnapshot( + applyAcpRegistryUrlAuthAction(current, Option.fromNullishOr(action)), ), ), ), - ); - return Effect.all( - [ - publishEnrichment, - publishLiveCommands, - publishLiveConfiguration, - publishUrlAuthAction, - ], - { - concurrency: "unbounded", - discard: true, - }, - ); - }, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build the ACP Registry provider snapshot.", - cause, - }), - ), - ); - - const provideAcpManagementServices = ( - effect: Effect.Effect< - A, - E, - | AcpRegistrySupport.AcpRegistryCatalog - | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto - >, - ): Effect.Effect => { - const provided = effect.pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Crypto.Crypto, crypto), + ), ); - return Option.isSome(runtimeCoordinator) - ? provided.pipe( - Effect.provideService( - AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, - runtimeCoordinator.value, - ), - ) - : provided; - }; + return Effect.all( + [publishEnrichment, publishLiveCommands, publishLiveConfiguration, publishUrlAuthAction], + { + concurrency: "unbounded", + discard: true, + }, + ); + }, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build the ACP Registry provider snapshot.", + cause, + }), + ), + ); - const clearLiveState = Option.isSome(runtimeCoordinator) - ? runtimeCoordinator.value - .clearLiveConfiguration(instanceId) - .pipe(Effect.andThen(runtimeCoordinator.value.clearAvailableCommands(instanceId))) - : Effect.void; - const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({ - instanceId, - settings: effectiveConfig, - cwd: host.paths.cwd, - environment: processEnvironment, - onChanged: (authenticated) => - confirmedAuthentication - .set(authenticated) - .pipe( - Effect.andThen(authenticated ? Effect.void : clearLiveState), - Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), - Effect.andThen(snapshot.refresh), - Effect.asVoid, - ), - }).pipe( + const provideAcpManagementServices = ( + effect: Effect.Effect< + A, + E, + | AcpRegistrySupport.AcpRegistryCatalog + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + >, + ): Effect.Effect => { + const provided = effect.pipe( Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Crypto.Crypto, crypto), ); - const auth = { - ...controller, - invalidate: (controller.invalidate ?? Effect.void).pipe( - Effect.andThen(confirmedAuthentication.set(false)), - Effect.andThen(clearLiveState), - Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), - Effect.andThen(snapshot.refresh), - Effect.asVoid, - ), - }; + return Option.isSome(runtimeCoordinator) + ? provided.pipe( + Effect.provideService( + AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, + runtimeCoordinator.value, + ), + ) + : provided; + }; - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - auth, - snapshot: { - ...snapshot, - refresh: snapshot.refresh.pipe( - Effect.tap(() => controller.refreshMethods ?? Effect.void), + const clearLiveState = Option.isSome(runtimeCoordinator) + ? runtimeCoordinator.value + .clearLiveConfiguration(instanceId) + .pipe(Effect.andThen(runtimeCoordinator.value.clearAvailableCommands(instanceId))) + : Effect.void; + const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({ + instanceId, + settings: effectiveConfig, + cwd: host.paths.cwd, + environment: processEnvironment, + onChanged: (authenticated) => + confirmedAuthentication + .set(authenticated) + .pipe( + Effect.andThen(authenticated ? Effect.void : clearLiveState), + Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), + Effect.andThen(snapshot.refresh), + Effect.asVoid, ), - }, - orchestrationAdapter, - textGeneration: makeUnsupportedTextGeneration(), - acpSessionManagement: { - listSessions: ({ cwd, cursor }) => - provideAcpManagementServices( - listAcpRegistrySessions({ - instanceId, - settings: effectiveConfig, - cwd, - environment: processEnvironment, - ...(cursor === undefined ? {} : { cursor }), - }), - ), - deleteSession: ({ cwd, sessionId }) => - provideAcpManagementServices( - deleteAcpRegistrySession({ - instanceId, - settings: effectiveConfig, - cwd, - environment: processEnvironment, - sessionId, - }), - ), - listProviders: (cwd) => - provideAcpManagementServices( - listAcpRegistryProviders({ - instanceId, - settings: effectiveConfig, - cwd, - environment: processEnvironment, - }), - ), - setProvider: ({ cwd, providerId, apiType, baseUrl, headers }) => - provideAcpManagementServices( - setAcpRegistryProvider({ - instanceId, - settings: effectiveConfig, - cwd, - environment: processEnvironment, - providerId, - apiType, - baseUrl, - ...(headers === undefined ? {} : { headers }), - }), - ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), - disableProvider: ({ cwd, providerId }) => - provideAcpManagementServices( - disableAcpRegistryProvider({ - instanceId, - settings: effectiveConfig, - cwd, - environment: processEnvironment, - providerId, - }), - ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), - logout: (cwd) => { - const logout = logoutAcpRegistry({ + }).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Crypto.Crypto, crypto), + ); + const auth = { + ...controller, + invalidate: (controller.invalidate ?? Effect.void).pipe( + Effect.andThen(confirmedAuthentication.set(false)), + Effect.andThen(clearLiveState), + Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), + Effect.andThen(snapshot.refresh), + Effect.asVoid, + ), + }; + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + auth, + snapshot: { + ...snapshot, + refresh: snapshot.refresh.pipe(Effect.tap(() => controller.refreshMethods ?? Effect.void)), + }, + orchestrationAdapter, + textGeneration: makeUnsupportedTextGeneration(), + acpSessionManagement: { + listSessions: ({ cwd, cursor }) => + provideAcpManagementServices( + listAcpRegistrySessions({ instanceId, settings: effectiveConfig, cwd, environment: processEnvironment, - }).pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Crypto.Crypto, crypto), - Effect.tap(() => confirmedAuthentication.set(false)), - Effect.tap(() => - liveSnapshotSemaphore.withPermit( - invalidateEnrichmentCache.pipe( - Effect.andThen( - Option.isSome(runtimeCoordinator) - ? Effect.all( - [ - runtimeCoordinator.value.clearAvailableCommands(instanceId), - runtimeCoordinator.value.clearLiveConfiguration(instanceId), - ], - { concurrency: "unbounded", discard: true }, - ) - : Effect.void, - ), + ...(cursor === undefined ? {} : { cursor }), + }), + ), + deleteSession: ({ cwd, sessionId }) => + provideAcpManagementServices( + deleteAcpRegistrySession({ + instanceId, + settings: effectiveConfig, + cwd, + environment: processEnvironment, + sessionId, + }), + ), + listProviders: (cwd) => + provideAcpManagementServices( + listAcpRegistryProviders({ + instanceId, + settings: effectiveConfig, + cwd, + environment: processEnvironment, + }), + ), + setProvider: ({ cwd, providerId, apiType, baseUrl, headers }) => + provideAcpManagementServices( + setAcpRegistryProvider({ + instanceId, + settings: effectiveConfig, + cwd, + environment: processEnvironment, + providerId, + apiType, + baseUrl, + ...(headers === undefined ? {} : { headers }), + }), + ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), + disableProvider: ({ cwd, providerId }) => + provideAcpManagementServices( + disableAcpRegistryProvider({ + instanceId, + settings: effectiveConfig, + cwd, + environment: processEnvironment, + providerId, + }), + ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), + logout: (cwd) => { + const logout = logoutAcpRegistry({ + instanceId, + settings: effectiveConfig, + cwd, + environment: processEnvironment, + }).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Crypto.Crypto, crypto), + Effect.tap(() => confirmedAuthentication.set(false)), + Effect.tap(() => + liveSnapshotSemaphore.withPermit( + invalidateEnrichmentCache.pipe( + Effect.andThen( + Option.isSome(runtimeCoordinator) + ? Effect.all( + [ + runtimeCoordinator.value.clearAvailableCommands(instanceId), + runtimeCoordinator.value.clearLiveConfiguration(instanceId), + ], + { concurrency: "unbounded", discard: true }, + ) + : Effect.void, ), ), ), - ); - return Option.isSome(runtimeCoordinator) - ? logout.pipe( - Effect.provideService( - AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, - runtimeCoordinator.value, - ), - ) - : logout; - }, + ), + ); + return Option.isSome(runtimeCoordinator) + ? logout.pipe( + Effect.provideService( + AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, + runtimeCoordinator.value, + ), + ) + : logout; }, - } satisfies ProviderInstance; - }), + }, + } satisfies ProviderInstance; + }), }; diff --git a/packages/provider-core/src/server/ProviderLatestVersions.ts b/packages/provider-core/src/server/ProviderLatestVersions.ts index 57b7129e4fa8..99884f368a2b 100644 --- a/packages/provider-core/src/server/ProviderLatestVersions.ts +++ b/packages/provider-core/src/server/ProviderLatestVersions.ts @@ -25,36 +25,33 @@ export class ProviderLatestVersions extends Context.Service< } >()("@t3tools/provider-core/server/ProviderLatestVersions") {} -export const make = (seed: Iterable = []) => - Effect.gen(function* () { - const entries = yield* Ref.make( - new Map( - Array.from(seed, ([key, version]) => [ - key, - { version, expiresAt: Number.MAX_SAFE_INTEGER }, - ]), - ), - ); - return ProviderLatestVersions.of({ - cached: (key, lookup, options) => - Effect.gen(function* () { - const now = DateTime.toEpochMillis(yield* DateTime.now); - const existing = (yield* Ref.get(entries)).get(key); - if (!options?.fresh && existing && existing.expiresAt > now) return existing.version; - const version = yield* lookup; - const ttl = version === null ? MISSING_TTL : FOUND_TTL; - yield* Ref.update(entries, (current) => - new Map(current).set(key, { version, expiresAt: now + Duration.toMillis(ttl) }), - ); - return version; - }), - invalidate: (key) => - Ref.update(entries, (current) => { - const next = new Map(current); - next.delete(key); - return next; - }), - }); +export const make = Effect.fn("ProviderLatestVersions.make")(function* ( + seed: Iterable = [], +) { + const entries = yield* Ref.make( + new Map( + Array.from(seed, ([key, version]) => [key, { version, expiresAt: Number.MAX_SAFE_INTEGER }]), + ), + ); + return ProviderLatestVersions.of({ + cached: Effect.fn("ProviderLatestVersions.cached")(function* (key, lookup, options) { + const now = DateTime.toEpochMillis(yield* DateTime.now); + const existing = (yield* Ref.get(entries)).get(key); + if (!options?.fresh && existing && existing.expiresAt > now) return existing.version; + const version = yield* lookup; + const ttl = version === null ? MISSING_TTL : FOUND_TTL; + yield* Ref.update(entries, (current) => + new Map(current).set(key, { version, expiresAt: now + Duration.toMillis(ttl) }), + ); + return version; + }), + invalidate: (key) => + Ref.update(entries, (current) => { + const next = new Map(current); + next.delete(key); + return next; + }), }); +}); export const layer = Layer.effect(ProviderLatestVersions, make()); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index c63534c3a89f..98ff340ddda5 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -192,8 +192,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); - const applyUsageLimits: ManagedServerProvider["applyUsageLimits"] = (update) => - Effect.gen(function* () { + // Providers call this on every token-usage tick, usually without changing the snapshot. + const applyUsageLimits: ManagedServerProvider["applyUsageLimits"] = Effect.fnUntraced( + function* (update) { const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { const usageLimits = applyUsageLimitsUpdate({ previous: state.snapshot.usageLimits, @@ -209,7 +210,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( if (snapshotToPublish !== null) { yield* PubSub.publish(changesPubSub, snapshotToPublish); } - }); + }, + ); const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { const nextSettings = yield* input.getSettings; diff --git a/packages/provider-core/src/server/providerAuthFlow.ts b/packages/provider-core/src/server/providerAuthFlow.ts index c596f8ebc3e5..7e30eab3147a 100644 --- a/packages/provider-core/src/server/providerAuthFlow.ts +++ b/packages/provider-core/src/server/providerAuthFlow.ts @@ -140,40 +140,42 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { const controller: ProviderAuth.ProviderAuthController = { credentialBinding: options.credentialBinding, - adoptCredentials: (update, stopSessions) => - Effect.gen(function* () { - yield* lock.withPermit( - Effect.gen(function* () { - if (operation !== "idle") - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "import", - detail: "Finish or cancel the existing sign-in first.", - }); - operation = "stopping"; - }), - ); - const result = yield* Effect.gen(function* () { - yield* stopOwnedSessions; - yield* stopSessions; - yield* update; - yield* refreshMethods; - }).pipe(Effect.exit); - const state: ProviderAuthState = { - ...empty, - methods: snapshot.value.state.methods ?? [], - phase: Exit.isSuccess(result) ? "succeeded" : "failed", - message: Exit.isSuccess(result) ? null : failureMessage(result.cause), - }; - yield* lock.withPermit( - Effect.gen(function* () { - yield* SubscriptionRef.set(snapshot, { owner: null, state }); - operation = "idle"; - }), - ); - if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); - return state; - }).pipe(Effect.uninterruptible), + adoptCredentials: Effect.fn("providerAuthFlow.adoptCredentials")(function* ( + update, + stopSessions, + ) { + yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "import", + detail: "Finish or cancel the existing sign-in first.", + }); + operation = "stopping"; + }), + ); + const result = yield* Effect.gen(function* () { + yield* stopOwnedSessions; + yield* stopSessions; + yield* update; + yield* refreshMethods; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + message: Exit.isSuccess(result) ? null : failureMessage(result.cause), + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }, Effect.uninterruptible), refreshMethods, invalidate: lock.withPermit( Effect.gen(function* () { @@ -279,21 +281,21 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { expiresAt: flow.expiresAt, ...(returnUrl ? { returnUrl } : {}), ...(callbackMode ? { callbackMode } : {}), - setInteraction: (interaction, respond, complete) => - Effect.gen(function* () { - if (active !== flow) return; - flow.respond = respond; - flow.complete = complete; - yield* publish(flow, { - phase: "waiting", - interaction, - authorizationUrl: - interaction.type === "browser" || interaction.type === "deviceCode" - ? interaction.url - : null, - message: "Complete sign-in to continue.", - }); - }), + // Terminal sign-in updates this for every output chunk within the auth operation. + setInteraction: Effect.fnUntraced(function* (interaction, respond, complete) { + if (active !== flow) return; + flow.respond = respond; + flow.complete = complete; + yield* publish(flow, { + phase: "waiting", + interaction, + authorizationUrl: + interaction.type === "browser" || interaction.type === "deviceCode" + ? interaction.url + : null, + message: "Complete sign-in to continue.", + }); + }), verifying: Effect.gen(function* () { flow.respond = undefined; flow.complete = undefined; @@ -424,69 +426,67 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { return snapshot.value.state; }), ), - cancel: (owner, id) => - Effect.gen(function* () { - const flow = yield* lock.withPermit( - Effect.gen(function* () { - const flow = yield* requireFlow(owner, id); - yield* publish(flow, { - phase: "cancelled", - interaction: null, - authorizationUrl: null, - expiresAt: null, - message: "Sign-in cancelled.", + cancel: Effect.fn("providerAuthFlow.cancel")(function* (owner, id) { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, id); + yield* publish(flow, { + phase: "cancelled", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: "Sign-in cancelled.", + }); + active = undefined; + operation = "stopping"; + return flow; + }), + ); + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + operation = "idle"; + return snapshot.value.state; + }, Effect.uninterruptible), + logout: Effect.fn("providerAuthFlow.logout")(function* (stopSessions) { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle" && operation !== "auth") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: "Provider setup is already stopping.", }); - active = undefined; - operation = "stopping"; - return flow; - }), - ); - if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); - if (flow.fiber) yield* Fiber.interrupt(flow.fiber); - operation = "idle"; - return snapshot.value.state; - }).pipe(Effect.uninterruptible), - logout: (stopSessions) => - Effect.gen(function* () { - const flow = yield* lock.withPermit( - Effect.gen(function* () { - if (operation !== "idle" && operation !== "auth") - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "logout", - detail: "Provider setup is already stopping.", - }); - operation = "stopping"; - const flow = active; - active = undefined; - return flow; - }), - ); - const result = yield* Effect.gen(function* () { - if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); - if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); - yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); - const message = yield* options.logout; - if (options.refreshMethodsAfterAuth) yield* refreshMethods; - return message; - }).pipe(Effect.exit); - const state: ProviderAuthState = { - ...empty, - methods: snapshot.value.state.methods ?? [], - phase: Exit.isSuccess(result) ? "idle" : "failed", - message: Exit.isSuccess(result) - ? (result.value ?? "Signed out.") - : "Could not sign out. Try again.", - }; - yield* lock.withPermit( - Effect.gen(function* () { - yield* SubscriptionRef.set(snapshot, { owner: null, state }); - operation = "idle"; - }), - ); - if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); - return state; - }).pipe(Effect.uninterruptible), + operation = "stopping"; + const flow = active; + active = undefined; + return flow; + }), + ); + const result = yield* Effect.gen(function* () { + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + const message = yield* options.logout; + if (options.refreshMethodsAfterAuth) yield* refreshMethods; + return message; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) + ? (result.value ?? "Signed out.") + : "Could not sign out. Try again.", + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }, Effect.uninterruptible), subscribe: (owner) => SubscriptionRef.changes(snapshot).pipe( Stream.map((current) => diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index b3f1ee5a2836..75ca132579d9 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -105,30 +105,32 @@ export function isCommandMissingCause(error: unknown): boolean { return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; } -export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn(command); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); +export const spawnAndCollect = Effect.fn("snapshotProbe.spawnAndCollect")(function* ( + binaryPath: string, + command: ChildProcess.Command, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); - const result: CommandResult = { stdout, stderr, code: exitCode }; - if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new ProviderCommandNotFoundError({ - binaryPath, - exitCode, - stdoutLength: stdout.length, - stderrLength: stderr.length, - }); - } - return result; - }).pipe(Effect.scoped); + const result: CommandResult = { stdout, stderr, code: exitCode }; + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { + return yield* new ProviderCommandNotFoundError({ + binaryPath, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + return result; +}, Effect.scoped); export function parseGenericCliVersion(output: string): string | null { // "opencode v2.0.3"-style output: the optional "v" has to be consumed first, diff --git a/packages/provider-core/src/server/snapshotSettings.ts b/packages/provider-core/src/server/snapshotSettings.ts index aa2991b47d5c..299911fcab90 100644 --- a/packages/provider-core/src/server/snapshotSettings.ts +++ b/packages/provider-core/src/server/snapshotSettings.ts @@ -27,22 +27,20 @@ export function haveProviderSnapshotSettingsChanged( return !Equal.equals(previous, next); } -export const makeProviderSnapshotSettingsSource = ( - provider: Settings, -): Effect.Effect< +export const makeProviderSnapshotSettingsSource = Effect.fn( + "snapshotSettings.makeProviderSnapshotSettingsSource", +)(function* (provider: Settings): Effect.fn.Return< { readonly getSettings: Effect.Effect, ServerSettingsError>; readonly streamSettings: Stream.Stream>; }, never, ProviderHost.ProviderHost -> => - Effect.gen(function* () { - const { settings } = yield* ProviderHost.ProviderHost; - const mapSettings = (current: ServerSettings) => - makeProviderSnapshotSettings(provider, current); - return { - getSettings: settings.get.pipe(Effect.map(mapSettings)), - streamSettings: settings.changes.pipe(Stream.map(mapSettings)), - }; - }); +> { + const { settings } = yield* ProviderHost.ProviderHost; + const mapSettings = (current: ServerSettings) => makeProviderSnapshotSettings(provider, current); + return { + getSettings: settings.get.pipe(Effect.map(mapSettings)), + streamSettings: settings.changes.pipe(Stream.map(mapSettings)), + }; +}); diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index 8ebdfbceb452..d5e87153ea12 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -276,17 +276,17 @@ function writtenIds( return new Set(emitted.flatMap((event) => (event.type === type ? [event.payload.id] : []))); } -export const endOrphanedNativeSubagent = (input: { - readonly projection: Pick; - readonly subagentId: NodeId; - readonly status: "completed" | "failed" | "interrupted" | "cancelled"; - readonly completedAt?: DateTime.Utc; - readonly result?: string | null; - readonly now: DateTime.Utc; - readonly emitted: ReadonlyArray; - readonly allocateEventId: () => Effect.Effect; -}) => - Effect.gen(function* () { +export const endOrphanedNativeSubagent = Effect.fn("subagentProjection.endOrphanedNativeSubagent")( + function* (input: { + readonly projection: Pick; + readonly subagentId: NodeId; + readonly status: "completed" | "failed" | "interrupted" | "cancelled"; + readonly completedAt?: DateTime.Utc; + readonly result?: string | null; + readonly now: DateTime.Utc; + readonly emitted: ReadonlyArray; + readonly allocateEventId: () => Effect.Effect; + }) { const { now, status, subagentId, completedAt = now } = input; const subagent = input.projection.subagents.find((entry) => entry.id === subagentId); if (subagent?.origin !== "provider_native") return []; @@ -351,9 +351,12 @@ export const endOrphanedNativeSubagent = (input: { }); } return events; - }); + }, +); -export const endRunlessRootTurns = (input: { +export const endRunlessRootTurns = Effect.fn("subagentProjection.endRunlessRootTurns")(function* < + E, +>(input: { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; readonly projection: Pick; @@ -362,63 +365,62 @@ export const endRunlessRootTurns = (input: { readonly now: DateTime.Utc; readonly emitted: ReadonlyArray; readonly allocateEventId: () => Effect.Effect; -}) => - Effect.gen(function* () { - const { now, status, completedAt = now } = input; - const endedNodeIds = writtenIds(input.emitted, "node.updated"); - const endedItemIds = writtenIds(input.emitted, "turn-item.updated"); - const scope = { - threadId: input.threadId, - providerInstanceId: input.providerInstanceId, - occurredAt: now, - }; - const events: Array = []; - for (const node of input.projection.nodes) { +}) { + const { now, status, completedAt = now } = input; + const endedNodeIds = writtenIds(input.emitted, "node.updated"); + const endedItemIds = writtenIds(input.emitted, "turn-item.updated"); + const scope = { + threadId: input.threadId, + providerInstanceId: input.providerInstanceId, + occurredAt: now, + }; + const events: Array = []; + for (const node of input.projection.nodes) { + if ( + node.kind !== "root_turn" || + node.runId !== null || + !isOrchestrationV2WorkActive(node.status) + ) { + continue; + } + if (!endedNodeIds.has(node.id)) { + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "node.updated", + nodeId: node.id, + payload: { ...node, status, completedAt }, + }); + } + for (const item of input.projection.turnItems) { if ( - node.kind !== "root_turn" || - node.runId !== null || - !isOrchestrationV2WorkActive(node.status) + item.nodeId !== node.id || + item.runId !== null || + !isOrchestrationV2WorkActive(item.status) || + endedItemIds.has(item.id) ) { continue; } - if (!endedNodeIds.has(node.id)) { - events.push({ - ...scope, - id: yield* input.allocateEventId(), - type: "node.updated", - nodeId: node.id, - payload: { ...node, status, completedAt }, - }); - } - for (const item of input.projection.turnItems) { - if ( - item.nodeId !== node.id || - item.runId !== null || - !isOrchestrationV2WorkActive(item.status) || - endedItemIds.has(item.id) - ) { - continue; - } - events.push({ - ...scope, - id: yield* input.allocateEventId(), - type: "turn-item.updated", - nodeId: node.id, - payload: { - ...item, - status: - status === "completed" && - (item.type === "command_execution" || item.type === "dynamic_tool") - ? "failed" - : status, - completedAt, - updatedAt: now, - ...(item.type === "reasoning" || item.type === "assistant_message" - ? { streaming: false } - : {}), - }, - }); - } + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "turn-item.updated", + nodeId: node.id, + payload: { + ...item, + status: + status === "completed" && + (item.type === "command_execution" || item.type === "dynamic_tool") + ? "failed" + : status, + completedAt, + updatedAt: now, + ...(item.type === "reasoning" || item.type === "assistant_message" + ? { streaming: false } + : {}), + }, + }); } - return events; - }); + } + return events; +}); From 05ad901e4947aacf7ddfea5945f1933e7bf4aed3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:30:58 -0700 Subject: [PATCH 004/458] refactor(provider): trace adapter operations without per-chunk spans (#21992) --- .../src/server/AcpSessionRuntime.ts | 2658 ++++++++--------- packages/provider-acp/src/server/adapter.ts | 135 +- .../src/server/mcpOverAcpBridge.ts | 162 +- .../provider-acp/src/server/nativeLogging.ts | 31 +- .../provider-acp/src/server/runtimeModel.ts | 33 +- .../src/server/accountUsage.ts | 16 +- packages/provider-cursor/src/server/auth.ts | 88 +- packages/provider-cursor/src/server/driver.ts | 418 +-- packages/provider-cursor/src/server/status.ts | 50 +- .../src/server/textGeneration.ts | 83 +- .../provider-grok/src/server/acpSupport.ts | 51 +- packages/provider-grok/src/server/adapter.ts | 35 +- packages/provider-grok/src/server/driver.ts | 266 +- packages/provider-grok/src/server/status.ts | 107 +- .../src/server/textGeneration.ts | 37 +- .../src/server/xaiAcpExtension.ts | 99 +- packages/provider-muse/src/server/adapter.ts | 83 +- packages/provider-muse/src/server/driver.ts | 258 +- .../provider-muse/src/server/maintenance.ts | 81 +- .../src/server/OpenCodeRuntime.ts | 605 ++-- .../src/server/OpenCodeServerLedger.ts | 116 +- .../provider-opencode/src/server/adapter.ts | 314 +- .../provider-opencode/src/server/driver.ts | 622 ++-- .../provider-opencode/src/server/status.ts | 71 +- .../src/server/v2/OpenCode2Client.ts | 56 +- .../src/server/v2/OpenCode2Server.ts | 15 +- .../src/server/v2/adapter.ts | 419 +-- .../src/server/v2/textGeneration.ts | 273 +- packages/provider-pi/src/server/adapter.ts | 317 +- packages/provider-pi/src/server/driver.ts | 244 +- packages/provider-pi/src/server/rpc.ts | 96 +- packages/provider-pi/src/server/status.ts | 111 +- .../provider-pi/src/server/textGeneration.ts | 53 +- 33 files changed, 4058 insertions(+), 3945 deletions(-) diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 424cb79b6b70..34201a35dbec 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -1256,1270 +1256,1264 @@ interface AcpActivePrompt { readonly completed: Deferred.Deferred; } -export const make = ( +export const make = Effect.fn("AcpSessionRuntime.make")(function* ( options: AcpSessionRuntimeOptions, -): Effect.Effect< +): Effect.fn.Return< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope -> => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeScope = yield* Scope.fork(yield* Scope.Scope); - const eventQueue = yield* Queue.unbounded(); - const modeStateRef = yield* Ref.make(undefined); - const toolCallsRef = yield* Ref.make(new Map()); - const shownToolCallIds = new Set(); - const assistantItemRuntimeId = yield* crypto.randomUUIDv4().pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to generate an ACP assistant item runtime identifier.", - cause, +> { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.fork(yield* Scope.Scope); + const eventQueue = yield* Queue.unbounded(); + const modeStateRef = yield* Ref.make(undefined); + const toolCallsRef = yield* Ref.make(new Map()); + const shownToolCallIds = new Set(); + const assistantItemRuntimeId = yield* crypto.randomUUIDv4().pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to generate an ACP assistant item runtime identifier.", + cause, + }), + ), + ); + const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); + const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const initializeStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const startupMetadataRef = yield* Ref.make>( + [], + ); + const notificationSemaphore = yield* Semaphore.make(1); + const terminationErrorRef = yield* Ref.make>( + Option.none(), + ); + const stoppingRef = yield* Ref.make(false); + const stderrFailure = yield* Deferred.make(); + const stderrTailRef = yield* Ref.make(""); + const homeDirectory = yield* HostProcess.HomeDirectory; + const stderrDrained = yield* Deferred.make(); + const runtimeClosed = yield* Deferred.make(); + const promptSerializationSemaphore = yield* Semaphore.make(1); + const sessionLoadSemaphore = yield* Semaphore.make(1); + const promptDispatchSemaphore = yield* Semaphore.make(1); + const activePromptRef = yield* Ref.make>(Option.none()); + const assistantUpdatesOpenRef = yield* Ref.make(true); + const sessionLoadGateRef = yield* Ref.make>(Option.none()); + + const ensureConnected = Effect.gen(function* () { + const error = yield* Ref.get(terminationErrorRef); + if (Option.isSome(error)) { + return yield* error.value; + } + if (yield* Ref.get(stoppingRef)) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "The ACP session runtime is closed.", + cause: undefined, + }); + } + }); + + const enrichProcessExitWithStderr = ( + error: EffectAcpErrors.AcpError, + ): Effect.Effect => + error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 + ? Effect.succeed(error) + : Deferred.await(stderrDrained).pipe( + Effect.timeout("250 millis"), + Effect.ignore, + Effect.andThen(Ref.get(stderrTailRef)), + Effect.map((tail) => { + const stderr = sanitizeAcpStderrExcerpt(tail, homeDirectory); + return stderr.length === 0 + ? error + : new EffectAcpErrors.AcpProcessExitedError({ + ...(error.code !== undefined ? { code: error.code } : {}), + ...(error.pid !== undefined ? { pid: error.pid } : {}), + stderr, + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }); }), - ), - ); - const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); - const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); - const initializeStateRef = yield* Ref.make({ _tag: "NotStarted" }); - const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); - const startupMetadataRef = yield* Ref.make>( - [], - ); - const notificationSemaphore = yield* Semaphore.make(1); - const terminationErrorRef = yield* Ref.make>( - Option.none(), + ); + + const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( + error: EffectAcpErrors.AcpError, + ) { + if (yield* Ref.get(stoppingRef)) { + return; + } + const enriched = yield* enrichProcessExitWithStderr(error); + const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => + Option.isSome(current) + ? ([false, current] as const) + : ([true, Option.some(enriched)] as const), ); - const stoppingRef = yield* Ref.make(false); - const stderrFailure = yield* Deferred.make(); - const stderrTailRef = yield* Ref.make(""); - const homeDirectory = yield* HostProcess.HomeDirectory; - const stderrDrained = yield* Deferred.make(); - const runtimeClosed = yield* Deferred.make(); - const promptSerializationSemaphore = yield* Semaphore.make(1); - const sessionLoadSemaphore = yield* Semaphore.make(1); - const promptDispatchSemaphore = yield* Semaphore.make(1); - const activePromptRef = yield* Ref.make>(Option.none()); - const assistantUpdatesOpenRef = yield* Ref.make(true); - const sessionLoadGateRef = yield* Ref.make>(Option.none()); - - const ensureConnected = Effect.gen(function* () { - const error = yield* Ref.get(terminationErrorRef); - if (Option.isSome(error)) { - return yield* error.value; - } - if (yield* Ref.get(stoppingRef)) { - return yield* new EffectAcpErrors.AcpTransportError({ - detail: "The ACP session runtime is closed.", - cause: undefined, - }); - } - }); + if (!firstTermination) { + return; + } + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); + }); - const enrichProcessExitWithStderr = ( - error: EffectAcpErrors.AcpError, - ): Effect.Effect => - error._tag !== "AcpProcessExitedError" || (error.stderr?.trim().length ?? 0) > 0 - ? Effect.succeed(error) - : Deferred.await(stderrDrained).pipe( - Effect.timeout("250 millis"), - Effect.ignore, - Effect.andThen(Ref.get(stderrTailRef)), - Effect.map((tail) => { - const stderr = sanitizeAcpStderrExcerpt(tail, homeDirectory); - return stderr.length === 0 - ? error - : new EffectAcpErrors.AcpProcessExitedError({ - ...(error.code !== undefined ? { code: error.code } : {}), - ...(error.pid !== undefined ? { pid: error.pid } : {}), - stderr, - ...(error.cause !== undefined ? { cause: error.cause } : {}), - }); + const logRequest = (event: AcpSessionRequestLogEvent) => + options.requestLogger ? options.requestLogger(event) : Effect.void; + + const runLoggedRequest = ( + method: string, + payload: unknown, + effect: Effect.Effect, + ): Effect.Effect => + logRequest({ method, payload, status: "started" }).pipe( + Effect.flatMap(() => + (options.onStderr ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) : effect).pipe( + Effect.catch((error) => + enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), + ), + Effect.tap((result) => + logRequest({ + method, + payload, + status: "succeeded", + result, }), - ); - - const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* ( - error: EffectAcpErrors.AcpError, - ) { - if (yield* Ref.get(stoppingRef)) { - return; - } - const enriched = yield* enrichProcessExitWithStderr(error); - const firstTermination = yield* Ref.modify(terminationErrorRef, (current) => - Option.isSome(current) - ? ([false, current] as const) - : ([true, Option.some(enriched)] as const), - ); - if (!firstTermination) { - return; - } - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error: enriched }); - }); - - const logRequest = (event: AcpSessionRequestLogEvent) => - options.requestLogger ? options.requestLogger(event) : Effect.void; - - const runLoggedRequest = ( - method: string, - payload: unknown, - effect: Effect.Effect, - ): Effect.Effect => - logRequest({ method, payload, status: "started" }).pipe( - Effect.flatMap(() => - (options.onStderr - ? Effect.raceFirst(effect, Deferred.await(stderrFailure)) - : effect - ).pipe( - Effect.catch((error) => - enrichProcessExitWithStderr(error).pipe(Effect.flatMap(Effect.fail)), - ), - Effect.tap((result) => - logRequest({ - method, - payload, - status: "succeeded", - result, - }), - ), - Effect.onError((cause) => - logRequest({ - method, - payload, - status: "failed", - cause, - }), - ), ), - ), - ); - - const spawnCommand = - options.spawn.shell === false - ? { command: options.spawn.command, args: options.spawn.args, shell: false } - : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { - ...(options.spawn.env ? { env: options.spawn.env } : {}), - extendEnv: options.spawn.extendEnv ?? true, - }); - const linuxCgroupLease = - options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" - ? yield* Effect.sync(() => { - try { - if (options.linuxCgroupController === null) return undefined; - return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); - } catch { - return undefined; - } - }) - : undefined; - // This is lifecycle containment for inherited provider work. It is not a - // security boundary against a provider that can modify its delegated cgroup. - if (linuxCgroupLease !== undefined) { - yield* Scope.addFinalizer( - runtimeScope, - Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( - Effect.ignoreCause({ log: true }), - ), - ); - } - const containedTargetCommand = - linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) - ? spawnCommand.command - : yield* Effect.gen(function* () { - const resolved = resolveLinuxCgroupTargetCommand( - spawnCommand.command, - options.spawn.cwd ?? options.cwd, - { ...process.env, ...options.spawn.env }, - ); - if (resolved !== undefined) return resolved; - return yield* new EffectAcpErrors.AcpSpawnError({ - command: options.spawn.command, - cause: new Error("Contained ACP command was not found on PATH"), - }); - }); - const scopedSpawnCommand = - linuxCgroupLease !== undefined || spawnCommand.shell - ? spawnCommand - : { - ...(yield* (yield* AgentScope).wrap({ - command: spawnCommand.command, - args: spawnCommand.args, - name: "acp", - threadId: yield* AgentScopeThreadId, - env: { ...process.env, ...options.spawn.env }, - })), - shell: false, - }; - const containedSpawnCommand = - linuxCgroupLease === undefined - ? scopedSpawnCommand - : { - ...wrapCommandForLinuxCgroup( - linuxCgroupLease, - containedTargetCommand, - spawnCommand.args, - ), - shell: false, - }; - const spawnEnvironment = - linuxCgroupLease === undefined - ? options.spawn.env - : { - ...options.spawn.env, - ELECTRON_RUN_AS_NODE: "1", - T3_ACP_CGROUP_WRAPPER: "1", - }; - const child = yield* spawner - .spawn( - ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { - ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(spawnEnvironment ? { env: spawnEnvironment } : {}), - extendEnv: options.spawn.extendEnv ?? true, - ...(options.ownDetachedProcessGroup === undefined - ? {} - : { detached: options.ownDetachedProcessGroup }), - shell: containedSpawnCommand.shell, - }), - ) - .pipe( - Effect.provideService(Scope.Scope, runtimeScope), - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpSpawnError({ - command: options.spawn.command, + Effect.onError((cause) => + logRequest({ + method, + payload, + status: "failed", cause, }), + ), ), - ); + ), + ); - const posixOwnershipLedger = new Map(); - const posixOwnershipFrontier = new Map(); - const posixOwnershipChildQueues = new Map>(); - const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; - const posixController = - options.ownDescendantProcessGroups === true - ? yield* Effect.try({ - try: () => { - if (options.posixProcessTreeController !== undefined) { - return options.posixProcessTreeController; - } - if (options.processGroupPlatform === undefined) { - throw new AcpProcessGroupTerminationError({ - detail: "POSIX ACP descendant ownership requires a supported host platform", - }); - } - return makePosixProcessTreeController(options.processGroupPlatform); - }, - catch: (cause) => - new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), - }) - : undefined; - if (posixController !== undefined) { - const observeOwnership = () => - observePosixOwnershipLedger({ - childQueues: posixOwnershipChildQueues, - controller: posixController, - frontier: posixOwnershipFrontier, - ledger: posixOwnershipLedger, - root: posixOwnershipRoot, - rootPid: Number(child.pid), + const spawnCommand = + options.spawn.shell === false + ? { command: options.spawn.command, args: options.spawn.args, shell: false } + : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { + ...(options.spawn.env ? { env: options.spawn.env } : {}), + extendEnv: options.spawn.extendEnv ?? true, }); - yield* Effect.try({ - try: observeOwnership, - catch: (cause) => - new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), - }); - yield* observePosixOwnershipLedgerContinuously({ + const linuxCgroupLease = + options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" + ? yield* Effect.sync(() => { + try { + if (options.linuxCgroupController === null) return undefined; + return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); + } catch { + return undefined; + } + }) + : undefined; + // This is lifecycle containment for inherited provider work. It is not a + // security boundary against a provider that can modify its delegated cgroup. + if (linuxCgroupLease !== undefined) { + yield* Scope.addFinalizer( + runtimeScope, + Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( + Effect.ignoreCause({ log: true }), + ), + ); + } + const containedTargetCommand = + linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) + ? spawnCommand.command + : yield* Effect.gen(function* () { + const resolved = resolveLinuxCgroupTargetCommand( + spawnCommand.command, + options.spawn.cwd ?? options.cwd, + { ...process.env, ...options.spawn.env }, + ); + if (resolved !== undefined) return resolved; + return yield* new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause: new Error("Contained ACP command was not found on PATH"), + }); + }); + const scopedSpawnCommand = + linuxCgroupLease !== undefined || spawnCommand.shell + ? spawnCommand + : { + ...(yield* (yield* AgentScope).wrap({ + command: spawnCommand.command, + args: spawnCommand.args, + name: "acp", + threadId: yield* AgentScopeThreadId, + env: { ...process.env, ...options.spawn.env }, + })), + shell: false, + }; + const containedSpawnCommand = + linuxCgroupLease === undefined + ? scopedSpawnCommand + : { + ...wrapCommandForLinuxCgroup(linuxCgroupLease, containedTargetCommand, spawnCommand.args), + shell: false, + }; + const spawnEnvironment = + linuxCgroupLease === undefined + ? options.spawn.env + : { + ...options.spawn.env, + ELECTRON_RUN_AS_NODE: "1", + T3_ACP_CGROUP_WRAPPER: "1", + }; + const child = yield* spawner + .spawn( + ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { + ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), + ...(spawnEnvironment ? { env: spawnEnvironment } : {}), + extendEnv: options.spawn.extendEnv ?? true, + ...(options.ownDetachedProcessGroup === undefined + ? {} + : { detached: options.ownDetachedProcessGroup }), + shell: containedSpawnCommand.shell, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause, + }), + ), + ); + + const posixOwnershipLedger = new Map(); + const posixOwnershipFrontier = new Map(); + const posixOwnershipChildQueues = new Map>(); + const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; + const posixController = + options.ownDescendantProcessGroups === true + ? yield* Effect.try({ + try: () => { + if (options.posixProcessTreeController !== undefined) { + return options.posixProcessTreeController; + } + if (options.processGroupPlatform === undefined) { + throw new AcpProcessGroupTerminationError({ + detail: "POSIX ACP descendant ownership requires a supported host platform", + }); + } + return makePosixProcessTreeController(options.processGroupPlatform); + }, + catch: (cause) => + new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), + }) + : undefined; + if (posixController !== undefined) { + const observeOwnership = () => + observePosixOwnershipLedger({ childQueues: posixOwnershipChildQueues, controller: posixController, frontier: posixOwnershipFrontier, ledger: posixOwnershipLedger, root: posixOwnershipRoot, rootPid: Number(child.pid), - }).pipe(Effect.forkIn(runtimeScope)); - } + }); + yield* Effect.try({ + try: observeOwnership, + catch: (cause) => + new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), + }); + yield* observePosixOwnershipLedgerContinuously({ + childQueues: posixOwnershipChildQueues, + controller: posixController, + frontier: posixOwnershipFrontier, + ledger: posixOwnershipLedger, + root: posixOwnershipRoot, + rootPid: Number(child.pid), + }).pipe(Effect.forkIn(runtimeScope)); + } - const signalOwnedProcessGroup = (signal: NodeJS.Signals) => - Effect.try({ - try: () => { - signalProcessGroup(Number(child.pid), signal); - return true; - }, - catch: (cause) => - new AcpProcessGroupTerminationError({ - cause, - detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, - }), - }).pipe( - Effect.catch((error) => { - const cause = error.cause as NodeJS.ErrnoException | undefined; - return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); + const signalOwnedProcessGroup = (signal: NodeJS.Signals) => + Effect.try({ + try: () => { + signalProcessGroup(Number(child.pid), signal); + return true; + }, + catch: (cause) => + new AcpProcessGroupTerminationError({ + cause, + detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, }), - ); - const terminateWindowsProcessTree = - options.windowsProcessTreeTerminator ?? - ((pid: number) => - terminateWindowsProcessTreeWithTaskkill(pid).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - )); - const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) => - Effect.gen(function* () { - const platform = platformOverride ?? options.processGroupPlatform; - if (platform === undefined || platform === "win32") { - return yield* new AcpProcessGroupTerminationError({ - detail: "POSIX ACP descendant ownership requires a supported host platform", - }); - } - const controller = yield* Effect.try({ - try: () => posixController ?? makePosixProcessTreeController(platform), - catch: (cause) => - isAcpProcessGroupTerminationError(cause) - ? cause - : new AcpProcessGroupTerminationError({ - cause, - detail: `Failed to initialize ACP descendant ownership on ${platform}`, - }), - }); - yield* terminatePosixOwnedProcessTree({ - controller, - grace, - ledger: posixOwnershipLedger, - root: posixOwnershipRoot, - rootPid: Number(child.pid), - }); - }); - const terminateOwnedProcessGroupImpl = Effect.gen(function* () { - if (options.ownDetachedProcessGroup !== true) return; - if (options.processGroupPlatform === undefined) { + }).pipe( + Effect.catch((error) => { + const cause = error.cause as NodeJS.ErrnoException | undefined; + return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); + }), + ); + const terminateWindowsProcessTree = + options.windowsProcessTreeTerminator ?? + ((pid: number) => + terminateWindowsProcessTreeWithTaskkill(pid).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + )); + const terminatePosixProcessTree = Effect.fn("AcpSessionRuntime.terminatePosixProcessTree")( + function* (grace: Duration.Input, platformOverride?: NodeJS.Platform) { + const platform = platformOverride ?? options.processGroupPlatform; + if (platform === undefined || platform === "win32") { return yield* new AcpProcessGroupTerminationError({ - detail: "Detached ACP process-group ownership requires a host platform", + detail: "POSIX ACP descendant ownership requires a supported host platform", }); } - if (options.processGroupPlatform === "win32") { - return yield* terminateWindowsProcessTree(Number(child.pid)); - } - if (linuxCgroupLease !== undefined) { - if (posixController !== undefined) { - yield* Effect.sync(() => - signalLinuxCgroupRootTerm({ - controller: posixController, - lease: linuxCgroupLease, - root: posixOwnershipRoot, - }), - ); - } - const grace = options.processGroupTerminationGrace ?? "1 second"; - if (grace !== 0) yield* Effect.sleep(grace); - return yield* terminateLinuxCgroupLease(linuxCgroupLease); - } - if (options.ownDescendantProcessGroups === true) { - return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); + const controller = yield* Effect.try({ + try: () => posixController ?? makePosixProcessTreeController(platform), + catch: (cause) => + isAcpProcessGroupTerminationError(cause) + ? cause + : new AcpProcessGroupTerminationError({ + cause, + detail: `Failed to initialize ACP descendant ownership on ${platform}`, + }), + }); + yield* terminatePosixOwnedProcessTree({ + controller, + grace, + ledger: posixOwnershipLedger, + root: posixOwnershipRoot, + rootPid: Number(child.pid), + }); + }, + ); + const terminateOwnedProcessGroupImpl = Effect.gen(function* () { + if (options.ownDetachedProcessGroup !== true) return; + if (options.processGroupPlatform === undefined) { + return yield* new AcpProcessGroupTerminationError({ + detail: "Detached ACP process-group ownership requires a host platform", + }); + } + if (options.processGroupPlatform === "win32") { + return yield* terminateWindowsProcessTree(Number(child.pid)); + } + if (linuxCgroupLease !== undefined) { + if (posixController !== undefined) { + yield* Effect.sync(() => + signalLinuxCgroupRootTerm({ + controller: posixController, + lease: linuxCgroupLease, + root: posixOwnershipRoot, + }), + ); } - const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); - if (!groupExisted) return; const grace = options.processGroupTerminationGrace ?? "1 second"; - if (grace !== 0) { - yield* Effect.sleep(grace); - } - yield* signalOwnedProcessGroup("SIGKILL"); - }).pipe(withWallClock); - const terminateProcessGroup = yield* Effect.cached( - Effect.uninterruptible(terminateOwnedProcessGroupImpl), + if (grace !== 0) yield* Effect.sleep(grace); + return yield* terminateLinuxCgroupLease(linuxCgroupLease); + } + if (options.ownDescendantProcessGroups === true) { + return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); + } + const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); + if (!groupExisted) return; + const grace = options.processGroupTerminationGrace ?? "1 second"; + if (grace !== 0) { + yield* Effect.sleep(grace); + } + yield* signalOwnedProcessGroup("SIGKILL"); + }).pipe(withWallClock); + const terminateProcessGroup = yield* Effect.cached( + Effect.uninterruptible(terminateOwnedProcessGroupImpl), + ); + if (options.ownDetachedProcessGroup === true) { + const hostPlatform = yield* HostProcess.Platform; + const forceTerminateOwnedProcessGroup = + hostPlatform === "win32" + ? terminateWindowsProcessTreeWithTaskkill(Number(child.pid)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ) + : linuxCgroupLease !== undefined + ? terminateLinuxCgroupLease(linuxCgroupLease) + : options.ownDescendantProcessGroups === true + ? terminatePosixProcessTree(0, hostPlatform) + : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); + yield* Scope.addFinalizer( + runtimeScope, + Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( + Effect.ignoreCause({ log: true }), + ), ); - if (options.ownDetachedProcessGroup === true) { - const hostPlatform = yield* HostProcess.Platform; - const forceTerminateOwnedProcessGroup = - hostPlatform === "win32" - ? terminateWindowsProcessTreeWithTaskkill(Number(child.pid)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ) - : linuxCgroupLease !== undefined - ? terminateLinuxCgroupLease(linuxCgroupLease) - : options.ownDescendantProcessGroups === true - ? terminatePosixProcessTree(0, hostPlatform) - : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); - yield* Scope.addFinalizer( - runtimeScope, - Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( - Effect.ignoreCause({ log: true }), + } + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( + Effect.andThen( + options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, ), - ); - } - yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - Ref.update(stderrTailRef, (current) => appendAcpStderrTail(current, chunk)).pipe( - Effect.andThen( - options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void, - ), - Effect.catch((error) => - Effect.gen(function* () { - yield* Deferred.fail(stderrFailure, error); - yield* recordTermination(error); - yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); - }), - ), + Effect.catch((error) => + Effect.gen(function* () { + yield* Deferred.fail(stderrFailure, error); + yield* recordTermination(error); + yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); + }), ), ), - Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); + ), + Effect.ensuring(Deferred.succeed(stderrDrained, undefined)), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); - const acpContext = yield* Layer.build( - EffectAcpClient.layerChildProcess(child, { - ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), - ...(options.transformSessionUpdate - ? { transformSessionUpdate: options.transformSessionUpdate } - : {}), - ...(options.protocolLogging?.logIncoming !== undefined - ? { logIncoming: options.protocolLogging.logIncoming } - : {}), - ...(options.protocolLogging?.logOutgoing !== undefined - ? { logOutgoing: options.protocolLogging.logOutgoing } - : {}), - ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), - ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), - onTermination: (error) => - recordTermination(error).pipe( - Effect.andThen(options.onTermination?.(error) ?? Effect.void), - Effect.ensuring( - Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), - ), + const acpContext = yield* Layer.build( + EffectAcpClient.layerChildProcess(child, { + ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}), + ...(options.transformSessionUpdate + ? { transformSessionUpdate: options.transformSessionUpdate } + : {}), + ...(options.protocolLogging?.logIncoming !== undefined + ? { logIncoming: options.protocolLogging.logIncoming } + : {}), + ...(options.protocolLogging?.logOutgoing !== undefined + ? { logOutgoing: options.protocolLogging.logOutgoing } + : {}), + ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), + ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), + onTermination: (error) => + recordTermination(error).pipe( + Effect.andThen(options.onTermination?.(error) ?? Effect.void), + Effect.ensuring( + Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), ), - ...(options.onOutgoingResponseFailure - ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } - : {}), - ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), - }), - ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - - const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); - - const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => - handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - configOptionsRef, - toolCallsRef, - shownToolCallIds, - assistantSegmentRef, - assistantItemRuntimeId, - params: notification, - }); - - yield* acp.handleSessionUpdate((notification) => - notificationSemaphore.withPermit( - Effect.gen(function* () { - if (Option.isSome(yield* Ref.get(terminationErrorRef))) { - return; - } - const gate = yield* Ref.get(sessionLoadGateRef); - // A different session can still have an in-flight prompt while this - // load replays history, so quarantine only the loading session. - const loadingThisSession = - Option.isSome(gate) && - gate.value.active && - notification.sessionId === gate.value.sessionId; - if ( - loadingThisSession && - sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId) - ) { - const lastActivityAtMillis = yield* Clock.currentTimeMillis; - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - ...gate.value, - lastActivityAtMillis, - }), - ); - } - if (sessionUpdateIsReplay(notification)) { - return; - } - const startState = yield* Ref.get(startStateRef); - if (startState._tag === "Starting") { - if (isStartupMetadataUpdate(notification)) { - yield* Ref.update(startupMetadataRef, (current) => - [ - ...current.filter( - (previous) => - previous.sessionId !== notification.sessionId || - previous.update.sessionUpdate !== notification.update.sessionUpdate, - ), - notification, - ].slice(-maxStartupMetadataUpdates), - ); - } - return; - } - if (loadingThisSession) { - if (notification.update.sessionUpdate === "config_option_update") { - const candidateConfigOptions = notification.update.configOptions; - yield* Ref.update(sessionLoadGateRef, (current) => - Option.isSome(current) - ? Option.some({ ...current.value, candidateConfigOptions }) - : current, - ); - } - return; - } - // One runtime projects one root ACP session. Child-session updates need - // explicit lineage routing and must never be flattened into this stream. - if ( - startState._tag !== "Started" || - notification.sessionId !== startState.result.sessionId - ) { - return; - } - if ( - !(yield* Ref.get(assistantUpdatesOpenRef)) && - (notification.update.sessionUpdate === "agent_message_chunk" || - notification.update.sessionUpdate === "agent_thought_chunk") - ) { - return; - } - yield* processSessionUpdate(notification); - }), - ), - ); - yield* Scope.addFinalizer( - runtimeScope, - Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))), - ); - const initializeClientCapabilities = { - fs: { - readTextFile: false, - writeTextFile: false, - ...options.clientCapabilities?.fs, - }, - terminal: options.clientCapabilities?.terminal ?? false, - ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), - ...(options.clientCapabilities?.elicitation - ? { elicitation: options.clientCapabilities.elicitation } + ), + ...(options.onOutgoingResponseFailure + ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } : {}), - ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), - } satisfies NonNullable; - - const getStartedState = Effect.gen(function* () { - yield* ensureConnected; - const state = yield* Ref.get(startStateRef); - if (state._tag === "Started") { - return state.result; - } - return yield* new EffectAcpErrors.AcpTransportError({ - detail: "ACP session runtime has not been started", - cause: "ACP session runtime has not been started", - }); + ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), + }), + ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); + + const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + + const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) => + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + configOptionsRef, + toolCallsRef, + shownToolCallIds, + assistantSegmentRef, + assistantItemRuntimeId, + params: notification, }); - const validateConfigOptionValue = ( - configId: string, - value: string | boolean, - ): Effect.Effect => + yield* acp.handleSessionUpdate((notification) => + notificationSemaphore.withPermit( Effect.gen(function* () { - const configOptions = yield* Ref.get(configOptionsRef); - const configOption = findSessionConfigOption(configOptions, configId); - if (!configOption) { + if (Option.isSome(yield* Ref.get(terminationErrorRef))) { + return; + } + const gate = yield* Ref.get(sessionLoadGateRef); + // A different session can still have an in-flight prompt while this + // load replays history, so quarantine only the loading session. + const loadingThisSession = + Option.isSome(gate) && + gate.value.active && + notification.sessionId === gate.value.sessionId; + if ( + loadingThisSession && + sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId) + ) { + const lastActivityAtMillis = yield* Clock.currentTimeMillis; + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + ...gate.value, + lastActivityAtMillis, + }), + ); + } + if (sessionUpdateIsReplay(notification)) { return; } - if (configOption.type === "boolean") { - if (typeof value === "boolean") { - return; + const startState = yield* Ref.get(startStateRef); + if (startState._tag === "Starting") { + if (isStartupMetadataUpdate(notification)) { + yield* Ref.update(startupMetadataRef, (current) => + [ + ...current.filter( + (previous) => + previous.sessionId !== notification.sessionId || + previous.update.sessionUpdate !== notification.update.sessionUpdate, + ), + notification, + ].slice(-maxStartupMetadataUpdates), + ); } - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32602, - errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, - data: { - configId: configOption.id, - expectedType: "boolean", - receivedValue: value, - }, - }); + return; } - if (typeof value !== "string") { - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32602, - errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, - data: { - configId: configOption.id, - expectedType: "string", - receivedValue: value, - }, - }); + if (loadingThisSession) { + if (notification.update.sessionUpdate === "config_option_update") { + const candidateConfigOptions = notification.update.configOptions; + yield* Ref.update(sessionLoadGateRef, (current) => + Option.isSome(current) + ? Option.some({ ...current.value, candidateConfigOptions }) + : current, + ); + } + return; } - const allowedValues = collectSessionConfigOptionValues(configOption); - if (allowedValues.includes(value)) { + // One runtime projects one root ACP session. Child-session updates need + // explicit lineage routing and must never be flattened into this stream. + if ( + startState._tag !== "Started" || + notification.sessionId !== startState.result.sessionId + ) { return; } if ( - configOption === findModelConfigOption(configOptions) && - options.customModels?.includes(value) + !(yield* Ref.get(assistantUpdatesOpenRef)) && + (notification.update.sessionUpdate === "agent_message_chunk" || + notification.update.sessionUpdate === "agent_thought_chunk") ) { return; } + yield* processSessionUpdate(notification); + }), + ), + ); + yield* Scope.addFinalizer( + runtimeScope, + Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))), + ); + const initializeClientCapabilities = { + fs: { + readTextFile: false, + writeTextFile: false, + ...options.clientCapabilities?.fs, + }, + terminal: options.clientCapabilities?.terminal ?? false, + ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), + ...(options.clientCapabilities?.elicitation + ? { elicitation: options.clientCapabilities.elicitation } + : {}), + ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), + } satisfies NonNullable; + + const getStartedState = Effect.gen(function* () { + yield* ensureConnected; + const state = yield* Ref.get(startStateRef); + if (state._tag === "Started") { + return state.result; + } + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "ACP session runtime has not been started", + cause: "ACP session runtime has not been started", + }); + }); + + const validateConfigOptionValue = Effect.fn("AcpSessionRuntime.validateConfigOptionValue")( + function* ( + configId: string, + value: string | boolean, + ): Effect.fn.Return { + const configOptions = yield* Ref.get(configOptionsRef); + const configOption = findSessionConfigOption(configOptions, configId); + if (!configOption) { + return; + } + if (configOption.type === "boolean") { + if (typeof value === "boolean") { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, + data: { + configId: configOption.id, + expectedType: "boolean", + receivedValue: value, + }, + }); + } + if (typeof value !== "string") { return yield* new EffectAcpErrors.AcpRequestError({ code: -32602, - errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, data: { configId: configOption.id, - allowedValues, + expectedType: "string", receivedValue: value, }, }); + } + const allowedValues = collectSessionConfigOptionValues(configOption); + if (allowedValues.includes(value)) { + return; + } + if ( + configOption === findModelConfigOption(configOptions) && + options.customModels?.includes(value) + ) { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, + data: { + configId: configOption.id, + allowedValues, + receivedValue: value, + }, }); + }, + ); - const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( - response: - | EffectAcpSchema.SetSessionConfigOptionResponse - | EffectAcpSchema.ForkSessionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse, - ) { - const configOptions = sessionConfigOptionsFromSetup(response); - yield* Ref.set(configOptionsRef, configOptions); - yield* Queue.offer(eventQueue, { - _tag: "ConfigOptionsUpdated", - configOptions, - rawPayload: response, - }); + const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( + response: + | EffectAcpSchema.SetSessionConfigOptionResponse + | EffectAcpSchema.ForkSessionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + ) { + const configOptions = sessionConfigOptionsFromSetup(response); + yield* Ref.set(configOptionsRef, configOptions); + yield* Queue.offer(eventQueue, { + _tag: "ConfigOptionsUpdated", + configOptions, + rawPayload: response, }); + }); - const updateCurrentModeId = (modeId: string): Effect.Effect => - Ref.update(modeStateRef, (current) => - current ? { ...current, currentModeId: modeId } : current, - ); + const updateCurrentModeId = (modeId: string): Effect.Effect => + Ref.update(modeStateRef, (current) => + current ? { ...current, currentModeId: modeId } : current, + ); - const adoptSession = ( - sessionId: string, - sessionSetupResult: - | EffectAcpSchema.ForkSessionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse, - ): Effect.Effect => - Effect.gen(function* () { - const current = yield* getStartedState; - const meta = sessionSetupResult._meta; - const syntheticReplayIdle = - meta !== null && - typeof meta === "object" && - !Array.isArray(meta) && - (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; - const extractedModelConfigId = extractModelConfigId(sessionSetupResult); - const nextModelConfigId = - extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); - const nextModeState = parseSessionModeState(sessionSetupResult); - if (nextModeState !== undefined) { - yield* Ref.set(modeStateRef, nextModeState); - } else if (!syntheticReplayIdle) { - yield* Ref.set(modeStateRef, undefined); - } - // Synthetic replay-idle load responses only carry initialize model/mode - // meta; preserve live configOptions so setConfigOption still validates. - if ( - sessionSetupResult.configOptions !== undefined && - sessionSetupResult.configOptions !== null - ) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); - } else if (!syntheticReplayIdle) { - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); - } - const nextState = { - sessionId, - initializeResult: current.initializeResult, - sessionSetupResult, - modelConfigId: nextModelConfigId, - } satisfies AcpStartedState; - yield* Ref.set(toolCallsRef, new Map()); - yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); - yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); - return nextState; - }); + const adoptSession = Effect.fn("AcpSessionRuntime.adoptSession")(function* ( + sessionId: string, + sessionSetupResult: + | EffectAcpSchema.ForkSessionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + ): Effect.fn.Return { + const current = yield* getStartedState; + const meta = sessionSetupResult._meta; + const syntheticReplayIdle = + meta !== null && + typeof meta === "object" && + !Array.isArray(meta) && + (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; + const extractedModelConfigId = extractModelConfigId(sessionSetupResult); + const nextModelConfigId = + extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); + const nextModeState = parseSessionModeState(sessionSetupResult); + if (nextModeState !== undefined) { + yield* Ref.set(modeStateRef, nextModeState); + } else if (!syntheticReplayIdle) { + yield* Ref.set(modeStateRef, undefined); + } + // Synthetic replay-idle load responses only carry initialize model/mode + // meta; preserve live configOptions so setConfigOption still validates. + if ( + sessionSetupResult.configOptions !== undefined && + sessionSetupResult.configOptions !== null + ) { + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + } else if (!syntheticReplayIdle) { + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + } + const nextState = { + sessionId, + initializeResult: current.initializeResult, + sessionSetupResult, + modelConfigId: nextModelConfigId, + } satisfies AcpStartedState; + yield* Ref.set(toolCallsRef, new Map()); + yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); + yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); + return nextState; + }); - const runLoadSessionWithReplayIdle = ( - loadPayload: EffectAcpSchema.LoadSessionRequest, - initializeResult: EffectAcpSchema.InitializeResponse, - ): Effect.Effect => - sessionLoadSemaphore.withPermits(1)( - Effect.gen(function* () { - const sessionLoadTimeout = Duration.fromInputUnsafe( - options.sessionLoadTimeout ?? defaultSessionLoadTimeout, - ); - const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( - options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, - ); + const runLoadSessionWithReplayIdle = ( + loadPayload: EffectAcpSchema.LoadSessionRequest, + initializeResult: EffectAcpSchema.InitializeResponse, + ): Effect.Effect => + sessionLoadSemaphore.withPermits(1)( + Effect.gen(function* () { + const sessionLoadTimeout = Duration.fromInputUnsafe( + options.sessionLoadTimeout ?? defaultSessionLoadTimeout, + ); + const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( + options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, + ); - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - active: true, - sessionId: loadPayload.sessionId, - lastActivityAtMillis: undefined, - idleGap: sessionLoadReplayIdleGap, - initializeResult, - }), - ); + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + active: true, + sessionId: loadPayload.sessionId, + lastActivityAtMillis: undefined, + idleGap: sessionLoadReplayIdleGap, + initializeResult, + }), + ); - return yield* Effect.gen(function* () { - yield* logRequest({ - method: "session/load", - payload: loadPayload, - status: "started", - }); + return yield* Effect.gen(function* () { + yield* logRequest({ + method: "session/load", + payload: loadPayload, + status: "started", + }); - const idleFiber = yield* waitForSessionLoadReplayIdle({ - gateRef: sessionLoadGateRef, - }).pipe(Effect.forkIn(runtimeScope)); - const loaded = yield* Effect.raceFirst( - acp.agent.loadSession(loadPayload), - Fiber.join(idleFiber), - ).pipe( - Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), - Effect.timeoutOption(sessionLoadTimeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/load", - detail: - "session/load timed out waiting for RPC response or replay idle gap", - cause: undefined, - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.tap((result) => - logRequest({ - method: "session/load", - payload: loadPayload, - status: "succeeded", - result, - }), - ), - Effect.onError((cause) => - logRequest({ - method: "session/load", - payload: loadPayload, - status: "failed", - cause, - }), - ), - ); + const idleFiber = yield* waitForSessionLoadReplayIdle({ + gateRef: sessionLoadGateRef, + }).pipe(Effect.forkIn(runtimeScope)); + const loaded = yield* Effect.raceFirst( + acp.agent.loadSession(loadPayload), + Fiber.join(idleFiber), + ).pipe( + Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), + Effect.timeoutOption(sessionLoadTimeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/load", + detail: "session/load timed out waiting for RPC response or replay idle gap", + cause: undefined, + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.tap((result) => + logRequest({ + method: "session/load", + payload: loadPayload, + status: "succeeded", + result, + }), + ), + Effect.onError((cause) => + logRequest({ + method: "session/load", + payload: loadPayload, + status: "failed", + cause, + }), + ), + ); - const gate = yield* Ref.get(sessionLoadGateRef); - const candidateConfigOptions = Option.isSome(gate) - ? gate.value.candidateConfigOptions - : undefined; - return loaded.configOptions == null && candidateConfigOptions !== undefined - ? { ...loaded, configOptions: candidateConfigOptions } - : loaded; - }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); - }), - ); + const gate = yield* Ref.get(sessionLoadGateRef); + const candidateConfigOptions = Option.isSome(gate) + ? gate.value.candidateConfigOptions + : undefined; + return loaded.configOptions == null && candidateConfigOptions !== undefined + ? { ...loaded, configOptions: candidateConfigOptions } + : loaded; + }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); + }), + ); - const setConfigOption = ( - configId: string, - value: string | boolean, - ): Effect.Effect => - validateConfigOptionValue(configId, value).pipe( - Effect.flatMap(() => getStartedState), - Effect.flatMap((started) => - Ref.get(configOptionsRef).pipe( - Effect.flatMap((configOptions) => { - const existing = findSessionConfigOption(configOptions, configId); - if (existing && configOptionCurrentValueMatches(existing, value)) { - return Effect.succeed({ - configOptions, - } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); - } - const requestPayload = - typeof value === "boolean" - ? ({ - sessionId: started.sessionId, - configId, - type: "boolean", - value, - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) - : ({ - sessionId: started.sessionId, - configId, - value: String(value), - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); - return runLoggedRequest( - "session/set_config_option", - requestPayload, - acp.agent.setSessionConfigOption(requestPayload), - ).pipe(Effect.tap((response) => updateConfigOptions(response))); - }), - ), + const setConfigOption = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + validateConfigOptionValue(configId, value).pipe( + Effect.flatMap(() => getStartedState), + Effect.flatMap((started) => + Ref.get(configOptionsRef).pipe( + Effect.flatMap((configOptions) => { + const existing = findSessionConfigOption(configOptions, configId); + if (existing && configOptionCurrentValueMatches(existing, value)) { + return Effect.succeed({ + configOptions, + } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); + } + const requestPayload = + typeof value === "boolean" + ? ({ + sessionId: started.sessionId, + configId, + type: "boolean", + value, + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) + : ({ + sessionId: started.sessionId, + configId, + value: String(value), + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); + return runLoggedRequest( + "session/set_config_option", + requestPayload, + acp.agent.setSessionConfigOption(requestPayload), + ).pipe(Effect.tap((response) => updateConfigOptions(response))); + }), ), - ); + ), + ); - const initializeOnce = Effect.gen(function* () { - const initializePayload = { - protocolVersion: 2, - clientCapabilities: initializeClientCapabilities, - clientInfo: options.clientInfo, - ...(options.initializeMeta === undefined ? {} : { _meta: options.initializeMeta }), - } satisfies EffectAcpSchema.InitializeRequest; - - const initializeResult = yield* runLoggedRequest( - "initialize", - initializePayload, - acp.agent.initialize(initializePayload), - ); - yield* options.onInitialized?.(initializeResult) ?? Effect.void; - return initializeResult; - }); + const initializeOnce = Effect.gen(function* () { + const initializePayload = { + protocolVersion: 2, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + ...(options.initializeMeta === undefined ? {} : { _meta: options.initializeMeta }), + } satisfies EffectAcpSchema.InitializeRequest; + + const initializeResult = yield* runLoggedRequest( + "initialize", + initializePayload, + acp.agent.initialize(initializePayload), + ); + yield* options.onInitialized?.(initializeResult) ?? Effect.void; + return initializeResult; + }); - const initialize = Effect.gen(function* () { - yield* ensureConnected; - const deferred = yield* Deferred.make< - EffectAcpSchema.InitializeResponse, - EffectAcpErrors.AcpError - >(); - const effect = yield* Ref.modify(initializeStateRef, (state) => { - switch (state._tag) { - case "Started": - return [Effect.succeed(state.result), state] as const; - case "Starting": - return [Deferred.await(state.deferred), state] as const; - case "NotStarted": - return [ - initializeOnce.pipe( - Effect.tap((result) => - Ref.set(initializeStateRef, { _tag: "Started", result }).pipe( - Effect.andThen(Deferred.succeed(deferred, result)), - ), + const initialize = Effect.gen(function* () { + yield* ensureConnected; + const deferred = yield* Deferred.make< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError + >(); + const effect = yield* Ref.modify(initializeStateRef, (state) => { + switch (state._tag) { + case "Started": + return [Effect.succeed(state.result), state] as const; + case "Starting": + return [Deferred.await(state.deferred), state] as const; + case "NotStarted": + return [ + initializeOnce.pipe( + Effect.tap((result) => + Ref.set(initializeStateRef, { _tag: "Started", result }).pipe( + Effect.andThen(Deferred.succeed(deferred, result)), ), - Effect.onError((cause) => - Deferred.failCause(deferred, cause).pipe( - Effect.andThen(Ref.set(initializeStateRef, { _tag: "NotStarted" })), - ), + ), + Effect.onError((cause) => + Deferred.failCause(deferred, cause).pipe( + Effect.andThen(Ref.set(initializeStateRef, { _tag: "NotStarted" })), ), ), - { _tag: "Starting", deferred } satisfies AcpInitializeState, - ] as const; - } - }); - return yield* effect; + ), + { _tag: "Starting", deferred } satisfies AcpInitializeState, + ] as const; + } }); + return yield* effect; + }); - const sessionMcpServers = ( - initializeResult: EffectAcpSchema.InitializeResponse, - activationOptions?: AcpSessionActivationOptions, - ): ReadonlyArray => { - const acpServers = activationOptions?.acpMcpServers ?? options.acpMcpServers ?? []; - if ( - initializeResult.agentCapabilities?.mcpCapabilities?.acp === true && - acpServers.length > 0 - ) { - return acpServers; - } - return activationOptions?.mcpServers ?? options.mcpServers ?? []; - }; + const sessionMcpServers = ( + initializeResult: EffectAcpSchema.InitializeResponse, + activationOptions?: AcpSessionActivationOptions, + ): ReadonlyArray => { + const acpServers = activationOptions?.acpMcpServers ?? options.acpMcpServers ?? []; + if ( + initializeResult.agentCapabilities?.mcpCapabilities?.acp === true && + acpServers.length > 0 + ) { + return acpServers; + } + return activationOptions?.mcpServers ?? options.mcpServers ?? []; + }; - const startOnce = Effect.gen(function* () { - const initializeResult = yield* initialize; + const startOnce = Effect.gen(function* () { + const initializeResult = yield* initialize; - const authenticateAfterRequired = ( + const authenticateAfterRequired = Effect.fn("AcpSessionRuntime.authenticateAfterRequired")( + function* ( authRequiredError: EffectAcpErrors.AcpError, - ): Effect.Effect => - Effect.gen(function* () { - const configuredAuthMethodId = options.authMethodId?.trim(); - const authMethod = selectAcpAgentAuthMethod( - initializeResult.authMethods, - configuredAuthMethodId, - ); - if ( - configuredAuthMethodId && - initializeResult.authMethods !== undefined && - authMethod === undefined - ) { - return yield* new EffectAcpErrors.AcpTransportError({ - detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, - cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, - }); - } - if ( - authMethod !== undefined && - authMethod.type !== undefined && - authMethod.type !== "agent" - ) { - return yield* new EffectAcpErrors.AcpTransportError({ - detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, - cause: authMethod, - }); - } - - const authMethodId = authMethod?.id ?? configuredAuthMethodId; - if (!authMethodId) { - return yield* authRequiredError; - } - - const authenticatePayload = { - methodId: authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); - }); - - const setupSession = Effect.gen(function* () { - let sessionId: string; - let sessionSetupResult: - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse; - const additionalDirectories = options.additionalDirectories?.length - ? { additionalDirectories: options.additionalDirectories } - : {}; - if (options.resumeSessionId) { - sessionId = options.resumeSessionId; - if ( - initializeResult.agentCapabilities?.loadSession === true && - options.resumeMethod !== "resume" - ) { - const loadPayload = { - sessionId, - cwd: options.cwd, - ...additionalDirectories, - mcpServers: sessionMcpServers(initializeResult), - } satisfies EffectAcpSchema.LoadSessionRequest; - sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); - } else if (initializeResult.agentCapabilities?.sessionCapabilities?.resume != null) { - const resumePayload = { - sessionId, - cwd: options.cwd, - ...additionalDirectories, - mcpServers: sessionMcpServers(initializeResult), - } satisfies EffectAcpSchema.ResumeSessionRequest; - sessionSetupResult = yield* runLoggedRequest( - "session/resume", - resumePayload, - acp.agent.resumeSession(resumePayload), - ); - } else { - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise session/load or session/resume support", - }); - } - } else { - const createPayload = { - cwd: options.cwd, - mcpServers: sessionMcpServers(initializeResult), - ...additionalDirectories, - } satisfies EffectAcpSchema.NewSessionRequest; - const created = yield* runLoggedRequest( - "session/new", - createPayload, - acp.agent.createSession(createPayload), - ); - sessionId = created.sessionId; - sessionSetupResult = created; + ): Effect.fn.Return { + const configuredAuthMethodId = options.authMethodId?.trim(); + const authMethod = selectAcpAgentAuthMethod( + initializeResult.authMethods, + configuredAuthMethodId, + ); + if ( + configuredAuthMethodId && + initializeResult.authMethods !== undefined && + authMethod === undefined + ) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: `ACP agent did not advertise configured authentication method "${configuredAuthMethodId}"`, + cause: { configuredAuthMethodId, authMethods: initializeResult.authMethods }, + }); + } + if ( + authMethod !== undefined && + authMethod.type !== undefined && + authMethod.type !== "agent" + ) { + return yield* new EffectAcpErrors.AcpTransportError({ + detail: `ACP authentication method "${authMethod.id}" requires ${authMethod.type} authentication, which cannot run inside a headless provider session`, + cause: authMethod, + }); } - return { sessionId, sessionSetupResult }; - }); + const authMethodId = authMethod?.id ?? configuredAuthMethodId; + if (!authMethodId) { + return yield* authRequiredError; + } - if (options.authenticateEagerly === true) { - // The configured method is authoritative here: Antigravity accepts - // methods (API key, Vertex) it does not list in `initialize`. const authenticatePayload = { - methodId: options.authMethodId ?? "", + methodId: authMethodId, } satisfies EffectAcpSchema.AuthenticateRequest; yield* runLoggedRequest( "authenticate", authenticatePayload, acp.agent.authenticate(authenticatePayload), ); + }, + ); + + const setupSession = Effect.gen(function* () { + let sessionId: string; + let sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + const additionalDirectories = options.additionalDirectories?.length + ? { additionalDirectories: options.additionalDirectories } + : {}; + if (options.resumeSessionId) { + sessionId = options.resumeSessionId; + if ( + initializeResult.agentCapabilities?.loadSession === true && + options.resumeMethod !== "resume" + ) { + const loadPayload = { + sessionId, + cwd: options.cwd, + ...additionalDirectories, + mcpServers: sessionMcpServers(initializeResult), + } satisfies EffectAcpSchema.LoadSessionRequest; + sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); + } else if (initializeResult.agentCapabilities?.sessionCapabilities?.resume != null) { + const resumePayload = { + sessionId, + cwd: options.cwd, + ...additionalDirectories, + mcpServers: sessionMcpServers(initializeResult), + } satisfies EffectAcpSchema.ResumeSessionRequest; + sessionSetupResult = yield* runLoggedRequest( + "session/resume", + resumePayload, + acp.agent.resumeSession(resumePayload), + ); + } else { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise session/load or session/resume support", + }); + } + } else { + const createPayload = { + cwd: options.cwd, + mcpServers: sessionMcpServers(initializeResult), + ...additionalDirectories, + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.agent.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; } - const { sessionId, sessionSetupResult } = yield* setupSession.pipe( - Effect.catch((error) => - !isAcpAuthenticationRequired(error) || options.authenticateOnAuthRequired === false - ? Effect.fail(error) - : authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)), - ), + + return { sessionId, sessionSetupResult }; + }); + + if (options.authenticateEagerly === true) { + // The configured method is authoritative here: Antigravity accepts + // methods (API key, Vertex) it does not list in `initialize`. + const authenticatePayload = { + methodId: options.authMethodId ?? "", + } satisfies EffectAcpSchema.AuthenticateRequest; + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), ); + } + const { sessionId, sessionSetupResult } = yield* setupSession.pipe( + Effect.catch((error) => + !isAcpAuthenticationRequired(error) || options.authenticateOnAuthRequired === false + ? Effect.fail(error) + : authenticateAfterRequired(error).pipe(Effect.andThen(setupSession)), + ), + ); - yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); - const nextState = { - sessionId, - initializeResult, - sessionSetupResult, - modelConfigId: extractModelConfigId(sessionSetupResult), - } satisfies AcpStartedState; - return nextState; - }); + const nextState = { + sessionId, + initializeResult, + sessionSetupResult, + modelConfigId: extractModelConfigId(sessionSetupResult), + } satisfies AcpStartedState; + return nextState; + }); - const start = Effect.gen(function* () { - yield* ensureConnected; - const deferred = yield* Deferred.make< - AcpSessionRuntimeStartResult, - EffectAcpErrors.AcpError - >(); - const effect = yield* Ref.modify(startStateRef, (state) => { - switch (state._tag) { - case "Started": - return [Effect.succeed(state.result), state] as const; - case "Starting": - return [Deferred.await(state.deferred), state] as const; - case "NotStarted": - return [ - startOnce.pipe( - Effect.tap((result) => - notificationSemaphore.withPermit( - Effect.gen(function* () { - const error = yield* Ref.get(terminationErrorRef); - if (Option.isSome(error)) { - return yield* error.value; - } - yield* Ref.set(startStateRef, { _tag: "Started", result }); - const metadata = yield* Ref.getAndSet(startupMetadataRef, []); - for (const notification of metadata) { - if (notification.sessionId === result.sessionId) { - yield* processSessionUpdate(notification); - } + const start = Effect.gen(function* () { + yield* ensureConnected; + const deferred = yield* Deferred.make(); + const effect = yield* Ref.modify(startStateRef, (state) => { + switch (state._tag) { + case "Started": + return [Effect.succeed(state.result), state] as const; + case "Starting": + return [Deferred.await(state.deferred), state] as const; + case "NotStarted": + return [ + startOnce.pipe( + Effect.tap((result) => + notificationSemaphore.withPermit( + Effect.gen(function* () { + const error = yield* Ref.get(terminationErrorRef); + if (Option.isSome(error)) { + return yield* error.value; + } + yield* Ref.set(startStateRef, { _tag: "Started", result }); + const metadata = yield* Ref.getAndSet(startupMetadataRef, []); + for (const notification of metadata) { + if (notification.sessionId === result.sessionId) { + yield* processSessionUpdate(notification); } - yield* Deferred.succeed(deferred, result); - }), - ), + } + yield* Deferred.succeed(deferred, result); + }), ), - Effect.onError((cause) => - Deferred.failCause(deferred, cause).pipe( - Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), - Effect.andThen(Ref.set(startupMetadataRef, [])), - ), + ), + Effect.onError((cause) => + Deferred.failCause(deferred, cause).pipe( + Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), + Effect.andThen(Ref.set(startupMetadataRef, [])), ), ), - { _tag: "Starting", deferred } satisfies AcpStartState, - ] as const; - } - }); - return yield* effect; - }); - - const drainEvents = Effect.gen(function* () { - if (yield* Ref.get(stoppingRef)) { - return; + ), + { _tag: "Starting", deferred } satisfies AcpStartState, + ] as const; } - const acknowledge = yield* Deferred.make(); - yield* notificationSemaphore.withPermit( - Effect.gen(function* () { - if (Option.isNone(yield* Ref.get(activePromptRef))) { - yield* Ref.set(assistantUpdatesOpenRef, false); - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - } - yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); - }), - ); - yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); - }); - - const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* ( - error: EffectAcpErrors.AcpError, - ) { - yield* recordTermination(error); - yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); }); + return yield* effect; + }); - const cancel = Effect.gen(function* () { - const started = yield* getStartedState; - const activePrompt = yield* Ref.get(activePromptRef); - const cancelPayload = { - sessionId: started.sessionId, - ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), - } satisfies EffectAcpSchema.CancelNotification; - if (options.cancelBehavior !== "wait-for-prompt") { - if (options.interruptPromptOnCancel === false) { - return yield* acp.agent.cancel(cancelPayload); - } - if (Option.isSome(activePrompt)) { - yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore); + const drainEvents = Effect.gen(function* () { + if (yield* Ref.get(stoppingRef)) { + return; + } + const acknowledge = yield* Deferred.make(); + yield* notificationSemaphore.withPermit( + Effect.gen(function* () { + if (Option.isNone(yield* Ref.get(activePromptRef))) { + yield* Ref.set(assistantUpdatesOpenRef, false); + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); } - // Write cancel before a replacement prompt can reach the agent. - yield* acp.agent.cancel(cancelPayload).pipe(Effect.ignore); - return; - } + yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge }); + }), + ); + yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed)); + }); - yield* acp.agent.cancel(cancelPayload); - if (Option.isNone(activePrompt)) { - return; + const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* ( + error: EffectAcpErrors.AcpError, + ) { + yield* recordTermination(error); + yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); + }); + + const cancel = Effect.gen(function* () { + const started = yield* getStartedState; + const activePrompt = yield* Ref.get(activePromptRef); + const cancelPayload = { + sessionId: started.sessionId, + ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }), + } satisfies EffectAcpSchema.CancelNotification; + if (options.cancelBehavior !== "wait-for-prompt") { + if (options.interruptPromptOnCancel === false) { + return yield* acp.agent.cancel(cancelPayload); } - const completed = yield* Effect.gen(function* () { - const result = yield* Fiber.await(activePrompt.value.fiber); - yield* Deferred.await(activePrompt.value.completed); - if (Option.isNone(yield* Ref.get(terminationErrorRef))) { - yield* drainEvents; - } - return result; - }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout)); - if (Option.isNone(completed)) { - const error = new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/cancel", - detail: "The ACP agent did not finish cancellation. Its process was stopped.", - cause: undefined, - }); - yield* retireRuntime(error); - return yield* error; + if (Option.isSome(activePrompt)) { + yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore); } - if (Exit.isFailure(completed.value)) { - return yield* Effect.failCause(completed.value.cause); + // Write cancel before a replacement prompt can reach the agent. + yield* acp.agent.cancel(cancelPayload).pipe(Effect.ignore); + return; + } + + yield* acp.agent.cancel(cancelPayload); + if (Option.isNone(activePrompt)) { + return; + } + const completed = yield* Effect.gen(function* () { + const result = yield* Fiber.await(activePrompt.value.fiber); + yield* Deferred.await(activePrompt.value.completed); + if (Option.isNone(yield* Ref.get(terminationErrorRef))) { + yield* drainEvents; } - }); + return result; + }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout)); + if (Option.isNone(completed)) { + const error = new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/cancel", + detail: "The ACP agent did not finish cancellation. Its process was stopped.", + cause: undefined, + }); + yield* retireRuntime(error); + return yield* error; + } + if (Exit.isFailure(completed.value)) { + return yield* Effect.failCause(completed.value.cause); + } + }); - return { - processContainment: - linuxCgroupLease !== undefined - ? "cgroup-v2" - : options.ownDescendantProcessGroups === true - ? "process-ledger-reduced-guarantee" - : options.ownDetachedProcessGroup === true - ? "process-group" - : "none", - handleRequestPermission: acp.handleRequestPermission, - handleElicitation: acp.handleElicitation, - handleMcpConnect: acp.handleMcpConnect, - handleMcpMessage: acp.handleMcpMessage, - handleMcpDisconnect: acp.handleMcpDisconnect, - handleMcpNotification: acp.handleMcpNotification, - handleReadTextFile: acp.handleReadTextFile, - handleWriteTextFile: acp.handleWriteTextFile, - handleCreateTerminal: acp.handleCreateTerminal, - handleTerminalOutput: acp.handleTerminalOutput, - handleTerminalWaitForExit: acp.handleTerminalWaitForExit, - handleTerminalKill: acp.handleTerminalKill, - handleTerminalRelease: acp.handleTerminalRelease, - handleSessionUpdate: acp.handleSessionUpdate, - handleElicitationComplete: acp.handleElicitationComplete, - handleUnknownExtRequest: acp.handleUnknownExtRequest, - handleUnknownExtNotification: acp.handleUnknownExtNotification, - handleExtRequest: acp.handleExtRequest, - handleExtNotification: acp.handleExtNotification, - initialize: () => initialize, - authenticate: (methodId) => - initialize.pipe( - Effect.andThen( - runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })), - ), - Effect.asVoid, - ), - start: () => start, - getEvents: () => Stream.fromQueue(eventQueue), - drainEvents, - getModeState: Ref.get(modeStateRef), - getConfigOptions: Ref.get(configOptionsRef), - loadSession: (sessionId, activationOptions) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId, - cwd: options.cwd, - mcpServers: sessionMcpServers(started.initializeResult, activationOptions), - } satisfies EffectAcpSchema.LoadSessionRequest; - return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); - }), - Effect.flatMap((response) => adoptSession(sessionId, response)), + return { + processContainment: + linuxCgroupLease !== undefined + ? "cgroup-v2" + : options.ownDescendantProcessGroups === true + ? "process-ledger-reduced-guarantee" + : options.ownDetachedProcessGroup === true + ? "process-group" + : "none", + handleRequestPermission: acp.handleRequestPermission, + handleElicitation: acp.handleElicitation, + handleMcpConnect: acp.handleMcpConnect, + handleMcpMessage: acp.handleMcpMessage, + handleMcpDisconnect: acp.handleMcpDisconnect, + handleMcpNotification: acp.handleMcpNotification, + handleReadTextFile: acp.handleReadTextFile, + handleWriteTextFile: acp.handleWriteTextFile, + handleCreateTerminal: acp.handleCreateTerminal, + handleTerminalOutput: acp.handleTerminalOutput, + handleTerminalWaitForExit: acp.handleTerminalWaitForExit, + handleTerminalKill: acp.handleTerminalKill, + handleTerminalRelease: acp.handleTerminalRelease, + handleSessionUpdate: acp.handleSessionUpdate, + handleElicitationComplete: acp.handleElicitationComplete, + handleUnknownExtRequest: acp.handleUnknownExtRequest, + handleUnknownExtNotification: acp.handleUnknownExtNotification, + handleExtRequest: acp.handleExtRequest, + handleExtNotification: acp.handleExtNotification, + initialize: () => initialize, + authenticate: (methodId) => + initialize.pipe( + Effect.andThen( + runLoggedRequest("authenticate", { methodId }, acp.agent.authenticate({ methodId })), ), - resumeSession: (sessionId, activationOptions) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId, - cwd: options.cwd, - mcpServers: sessionMcpServers(started.initializeResult, activationOptions), - } satisfies EffectAcpSchema.ResumeSessionRequest; - return runLoggedRequest( - "session/resume", - requestPayload, - acp.agent.resumeSession(requestPayload), - ); - }), - Effect.flatMap((response) => adoptSession(sessionId, response)), + Effect.asVoid, + ), + start: () => start, + getEvents: () => Stream.fromQueue(eventQueue), + drainEvents, + getModeState: Ref.get(modeStateRef), + getConfigOptions: Ref.get(configOptionsRef), + loadSession: (sessionId, activationOptions) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId, + cwd: options.cwd, + mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + } satisfies EffectAcpSchema.LoadSessionRequest; + return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); + }), + Effect.flatMap((response) => adoptSession(sessionId, response)), + ), + resumeSession: (sessionId, activationOptions) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId, + cwd: options.cwd, + mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + } satisfies EffectAcpSchema.ResumeSessionRequest; + return runLoggedRequest( + "session/resume", + requestPayload, + acp.agent.resumeSession(requestPayload), + ); + }), + Effect.flatMap((response) => adoptSession(sessionId, response)), + ), + forkSession: (sessionId, activationOptions) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId, + cwd: options.cwd, + mcpServers: sessionMcpServers(started.initializeResult, activationOptions), + } satisfies EffectAcpSchema.ForkSessionRequest; + return runLoggedRequest( + "session/fork", + requestPayload, + acp.agent.forkSession(requestPayload), + ); + }), + Effect.flatMap((response) => adoptSession(response.sessionId, response)), + ), + listSessions: (cursor) => { + const requestPayload = { + cwd: options.cwd, + ...(cursor === undefined ? {} : { cursor }), + } satisfies EffectAcpSchema.ListSessionsRequest; + return initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.sessionCapabilities?.list != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise session/list support", + }), ), - forkSession: (sessionId, activationOptions) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId, - cwd: options.cwd, - mcpServers: sessionMcpServers(started.initializeResult, activationOptions), - } satisfies EffectAcpSchema.ForkSessionRequest; - return runLoggedRequest( - "session/fork", - requestPayload, - acp.agent.forkSession(requestPayload), - ); - }), - Effect.flatMap((response) => adoptSession(response.sessionId, response)), + Effect.andThen( + runLoggedRequest("session/list", requestPayload, acp.agent.listSessions(requestPayload)), ), - listSessions: (cursor) => { - const requestPayload = { - cwd: options.cwd, - ...(cursor === undefined ? {} : { cursor }), - } satisfies EffectAcpSchema.ListSessionsRequest; - return initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.sessionCapabilities?.list != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise session/list support", - }), - ), - Effect.andThen( - runLoggedRequest( - "session/list", - requestPayload, - acp.agent.listSessions(requestPayload), - ), - ), - ); - }, - closeSession: (sessionId) => - start.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId: sessionId ?? started.sessionId, - } satisfies EffectAcpSchema.CloseSessionRequest; - return runLoggedRequest( - "session/close", - requestPayload, - acp.agent.closeSession(requestPayload), - ); - }), + ); + }, + closeSession: (sessionId) => + start.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: sessionId ?? started.sessionId, + } satisfies EffectAcpSchema.CloseSessionRequest; + return runLoggedRequest( + "session/close", + requestPayload, + acp.agent.closeSession(requestPayload), + ); + }), + ), + deleteSession: (sessionId) => + initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.sessionCapabilities?.delete != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise session/delete support", + }), ), - deleteSession: (sessionId) => - initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.sessionCapabilities?.delete != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise session/delete support", - }), - ), - Effect.andThen( - runLoggedRequest( - "session/delete", - { sessionId }, - acp.agent.deleteSession({ sessionId }), - ), - ), + Effect.andThen( + runLoggedRequest("session/delete", { sessionId }, acp.agent.deleteSession({ sessionId })), ), - listProviders: initialize.pipe( + ), + listProviders: initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.providers != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise provider configuration support", + }), + ), + Effect.andThen(runLoggedRequest("providers/list", {}, acp.agent.listProviders({}))), + ), + setProvider: (provider) => + initialize.pipe( Effect.filterOrFail( (initialized) => initialized.agentCapabilities?.providers != null, () => @@ -2528,185 +2522,168 @@ export const make = ( errorMessage: "ACP agent does not advertise provider configuration support", }), ), - Effect.andThen(runLoggedRequest("providers/list", {}, acp.agent.listProviders({}))), - ), - setProvider: (provider) => - initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.providers != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise provider configuration support", - }), - ), - Effect.andThen( - runLoggedRequest( - "providers/set", - { - ...provider, - ...(provider.headers === undefined - ? {} - : { - headers: Object.fromEntries( - Object.keys(provider.headers).map((name) => [name, "[redacted]"]), - ), - }), - }, - acp.agent.setProvider(provider), - ), + Effect.andThen( + runLoggedRequest( + "providers/set", + { + ...provider, + ...(provider.headers === undefined + ? {} + : { + headers: Object.fromEntries( + Object.keys(provider.headers).map((name) => [name, "[redacted]"]), + ), + }), + }, + acp.agent.setProvider(provider), ), ), - disableProvider: (providerId) => { - const request = { providerId } satisfies EffectAcpSchema.DisableProviderRequest; - return initialize.pipe( - Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.providers != null, - () => - new EffectAcpErrors.AcpRequestError({ - code: -32601, - errorMessage: "ACP agent does not advertise provider configuration support", - }), - ), - Effect.andThen( - runLoggedRequest("providers/disable", request, acp.agent.disableProvider(request)), - ), - ); - }, - logout: initialize.pipe( + ), + disableProvider: (providerId) => { + const request = { providerId } satisfies EffectAcpSchema.DisableProviderRequest; + return initialize.pipe( Effect.filterOrFail( - (initialized) => initialized.agentCapabilities?.auth?.logout != null, + (initialized) => initialized.agentCapabilities?.providers != null, () => new EffectAcpErrors.AcpRequestError({ code: -32601, - errorMessage: "ACP agent does not advertise logout support", + errorMessage: "ACP agent does not advertise provider configuration support", }), ), - Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), + Effect.andThen( + runLoggedRequest("providers/disable", request, acp.agent.disableProvider(request)), + ), + ); + }, + logout: initialize.pipe( + Effect.filterOrFail( + (initialized) => initialized.agentCapabilities?.auth?.logout != null, + () => + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise logout support", + }), ), - prompt: (payload, promptOptions?) => - promptSerializationSemaphore.withPermit( - Effect.acquireUseRelease( - promptDispatchSemaphore.withPermit( - Effect.gen(function* () { - const started = yield* getStartedState; - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Ref.set(assistantUpdatesOpenRef, true); - const requestPayload = { - sessionId: started.sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - const completed = yield* Deferred.make(); - const fiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - const active = { fiber, completed } satisfies AcpActivePrompt; - yield* Ref.set(activePromptRef, Option.some(active)); - if (promptOptions?.dispatched) { - yield* Deferred.succeed(promptOptions.dispatched, undefined); - } - return active; - }).pipe(notificationSemaphore.withPermit), - ), - (activePrompt) => - Fiber.join(activePrompt.fiber).pipe( - Effect.catchCause((cause) => - options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) - ? Effect.succeed({ - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse) - : Effect.failCause(cause), - ), - Effect.tap(() => - closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), - ), - ), - (activePrompt, result) => - Effect.gen(function* () { - if ( - options.cancelBehavior === "wait-for-prompt" && - Exit.isFailure(result) && - Cause.hasInterrupts(result.cause) - ) { - yield* retireRuntime( - new EffectAcpErrors.AcpTransportError({ - method: "session/prompt", - detail: "The ACP prompt stopped before the agent confirmed completion.", - cause: undefined, - }), - ); - } - yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); - yield* Ref.set(activePromptRef, Option.none()); - yield* Deferred.succeed(activePrompt.completed, undefined); - }), + Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), + ), + prompt: (payload, promptOptions?) => + promptSerializationSemaphore.withPermit( + Effect.acquireUseRelease( + promptDispatchSemaphore.withPermit( + Effect.gen(function* () { + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Ref.set(assistantUpdatesOpenRef, true); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const completed = yield* Deferred.make(); + const fiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + const active = { fiber, completed } satisfies AcpActivePrompt; + yield* Ref.set(activePromptRef, Option.some(active)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return active; + }).pipe(notificationSemaphore.withPermit), ), + (activePrompt) => + Fiber.join(activePrompt.fiber).pipe( + Effect.catchCause((cause) => + options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) + ? Effect.succeed({ + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse) + : Effect.failCause(cause), + ), + Effect.tap(() => + closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), + ), + ), + (activePrompt, result) => + Effect.gen(function* () { + if ( + options.cancelBehavior === "wait-for-prompt" && + Exit.isFailure(result) && + Cause.hasInterrupts(result.cause) + ) { + yield* retireRuntime( + new EffectAcpErrors.AcpTransportError({ + method: "session/prompt", + detail: "The ACP prompt stopped before the agent confirmed completion.", + cause: undefined, + }), + ); + } + yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); + yield* Ref.set(activePromptRef, Option.none()); + yield* Deferred.succeed(activePrompt.completed, undefined); + }), ), - cancel: - options.cancelBehavior === "wait-for-prompt" - ? promptDispatchSemaphore.withPermit(cancel) - : cancel, - ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), - // A session's mode is its `category: "mode"` config option. ACP v1 agents - // that only advertise `modes` (gemini-cli) take `session/set_mode` - // instead, which ACP v2 removed. - setMode: (modeId) => - Effect.gen(function* () { - const modeState = yield* Ref.get(modeStateRef); - if (modeState?.currentModeId === modeId) { - return {} satisfies EffectAcpSchema.SetSessionModeResponse; - } - const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( - (option) => option.category === "mode" && option.type === "select", - ); - if (modeConfigOption === undefined && modeState !== undefined) { - const started = yield* getStartedState; - const payload = { sessionId: started.sessionId, modeId }; - yield* runLoggedRequest("session/set_mode", payload, acp.agent.setSessionMode(payload)); - yield* updateCurrentModeId(modeId); - return {} satisfies EffectAcpSchema.SetSessionModeResponse; - } - const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); - const reported = parseSessionModeState({ configOptions: response.configOptions }); - if (reported === undefined) { - yield* updateCurrentModeId(modeId); - } else { - yield* updateCurrentModeId(reported.currentModeId); - } - return {} satisfies EffectAcpSchema.SetSessionModeResponse; + ), + cancel: + options.cancelBehavior === "wait-for-prompt" + ? promptDispatchSemaphore.withPermit(cancel) + : cancel, + ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), + // A session's mode is its `category: "mode"` config option. ACP v1 agents + // that only advertise `modes` (gemini-cli) take `session/set_mode` + // instead, which ACP v2 removed. + setMode: Effect.fn("AcpSessionRuntime.setMode")(function* (modeId) { + const modeState = yield* Ref.get(modeStateRef); + if (modeState?.currentModeId === modeId) { + return {} satisfies EffectAcpSchema.SetSessionModeResponse; + } + const modeConfigOption = (yield* Ref.get(configOptionsRef))?.find( + (option) => option.category === "mode" && option.type === "select", + ); + if (modeConfigOption === undefined && modeState !== undefined) { + const started = yield* getStartedState; + const payload = { sessionId: started.sessionId, modeId }; + yield* runLoggedRequest("session/set_mode", payload, acp.agent.setSessionMode(payload)); + yield* updateCurrentModeId(modeId); + return {} satisfies EffectAcpSchema.SetSessionModeResponse; + } + const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); + const reported = parseSessionModeState({ configOptions: response.configOptions }); + if (reported === undefined) { + yield* updateCurrentModeId(modeId); + } else { + yield* updateCurrentModeId(reported.currentModeId); + } + return {} satisfies EffectAcpSchema.SetSessionModeResponse; + }), + setSessionModel: (modelId, meta) => + getStartedState.pipe( + Effect.flatMap((started) => { + const payload = { + sessionId: started.sessionId, + modelId, + ...(meta === undefined ? {} : { _meta: meta }), + }; + return runLoggedRequest("session/set_model", payload, acp.agent.setSessionModel(payload)); }), - setSessionModel: (modelId, meta) => - getStartedState.pipe( - Effect.flatMap((started) => { - const payload = { - sessionId: started.sessionId, - modelId, - ...(meta === undefined ? {} : { _meta: meta }), - }; - return runLoggedRequest( - "session/set_model", - payload, - acp.agent.setSessionModel(payload), - ); - }), - ), - setConfigOption, - setModel: (model) => - getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), - Effect.asVoid, - ), + ), + setConfigOption, + setModel: (model) => + getStartedState.pipe( + Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.asVoid, + ), - request: (method, payload) => - ensureConnected.pipe( - Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))), - ), - notify: (method, payload) => - ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))), - } satisfies AcpSessionRuntime["Service"]; - }); + request: (method, payload) => + ensureConnected.pipe( + Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))), + ), + notify: (method, payload) => + ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))), + } satisfies AcpSessionRuntime["Service"]; +}); export const layer = ( options: AcpSessionRuntimeOptions, @@ -2751,26 +2728,27 @@ function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotificati } } -const handleSessionUpdate = ({ - queue, - modeStateRef, - configOptionsRef, - toolCallsRef, - shownToolCallIds, - assistantSegmentRef, - assistantItemRuntimeId, - params, -}: { - readonly queue: Queue.Queue; - readonly modeStateRef: Ref.Ref; - readonly configOptionsRef: Ref.Ref>; - readonly toolCallsRef: Ref.Ref>; - readonly shownToolCallIds: Set; - readonly assistantSegmentRef: Ref.Ref; - readonly assistantItemRuntimeId: string; - readonly params: EffectAcpSchema.SessionNotification; -}): Effect.Effect => - Effect.gen(function* () { +const handleSessionUpdate = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ({ + queue, + modeStateRef, + configOptionsRef, + toolCallsRef, + shownToolCallIds, + assistantSegmentRef, + assistantItemRuntimeId, + params, + }: { + readonly queue: Queue.Queue; + readonly modeStateRef: Ref.Ref; + readonly configOptionsRef: Ref.Ref>; + readonly toolCallsRef: Ref.Ref>; + readonly shownToolCallIds: Set; + readonly assistantSegmentRef: Ref.Ref; + readonly assistantItemRuntimeId: string; + readonly params: EffectAcpSchema.SessionNotification; + }): Effect.fn.Return { if (params.update.sessionUpdate === "config_option_update") { const configOptions = params.update.configOptions; yield* Ref.set(configOptionsRef, configOptions); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 0a2b7fa39481..5d7ea0b7d288 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1568,12 +1568,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return Option.some(yield* effect); }), ); - const registerNativeResponseAcknowledgement = ( - generation: number, - transportRequestId: string, - acknowledgement: Deferred.Deferred, - ) => - Effect.gen(function* () { + const registerNativeResponseAcknowledgement = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + generation: number, + transportRequestId: string, + acknowledgement: Deferred.Deferred, + ) { yield* Ref.update(nativeResponseAcknowledgements, (current) => { const updated = new Map(current); updated.set(transportRequestId, { acknowledgement, generation }); @@ -1813,8 +1814,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const endedBackgroundTaskIds = yield* Ref.make>(new Set()); const endedBackgroundTaskIdLimit = 128; - const setBackgroundTaskRunning = (taskId: string, running: boolean) => - Effect.gen(function* () { + const setBackgroundTaskRunning = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (taskId: string, running: boolean) { if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) { return; } @@ -1849,10 +1851,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? setBackgroundTaskRunning(mutation.taskId, true) : markBackgroundTaskEnded(mutation.taskId); - const trackRunningBackgroundTools = ( - notification: EffectAcpSchema.SessionNotification, - ): Effect.Effect => - Effect.gen(function* () { + const trackRunningBackgroundTools = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + notification: EffectAcpSchema.SessionNotification, + ): Effect.fn.Return { if (flavor.extractBackgroundTaskId === undefined) return; for (const event of parseSessionUpdateEvent(notification).events) { if (event._tag !== "ToolCallUpdated") continue; @@ -2836,8 +2839,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return false; }; - const rearmDeferredFinalize = (context: ActiveAcpTurn) => - Effect.gen(function* () { + const rearmDeferredFinalize = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (context: ActiveAcpTurn) { if (!flavor.deferFinalizeForBackgroundWork) return; if (!context.promptSettled || context.finalized) return; if (hasDeferredBackgroundWork(context)) return; @@ -2851,8 +2855,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( projectedStatus?: ProjectedToolStatus, ) => Effect.Effect = () => Effect.void; - const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) => - Effect.gen(function* () { + const markAwaitingBackgroundHydration = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (context: ActiveAcpTurn, taskId: string) { if (context.awaitingBackgroundHydration.has(taskId)) return; context.awaitingBackgroundHydration.add(taskId); yield* Effect.gen(function* () { @@ -2871,8 +2876,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); }); - const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => - Effect.gen(function* () { + const markPendingInjectedReport = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (context: ActiveAcpTurn, taskId: string) { if (!context.promptSettled) return; if (context.pendingInjectedReport.has(taskId)) return; context.pendingInjectedReport.add(taskId); @@ -3421,13 +3427,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); }); - const appendLoadedHistory = ( - notification: EffectAcpSchema.SessionNotification, - role: "user" | "assistant" | "thought", - text: string, - replace = false, - ) => - Effect.gen(function* () { + const appendLoadedHistory = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + notification: EffectAcpSchema.SessionNotification, + role: "user" | "assistant" | "thought", + text: string, + replace = false, + ) { if (text.length === 0 && !replace) return; const now = yield* DateTime.now; yield* Ref.update(snapshot, (current) => { @@ -5088,47 +5095,47 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); - const projectAcpRuntimeSessionUpdateEffect = ( - rawNotification: EffectAcpSchema.SessionNotification, - ) => - Effect.gen(function* () { - if (clientTerminals !== undefined) { - const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); - if (embedded !== undefined) { - rememberEmbeddedTerminals({ - ...embedded, - sessionId: rawNotification.sessionId, - }); + const projectAcpRuntimeSessionUpdateEffect = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced( + function* (rawNotification: EffectAcpSchema.SessionNotification) { + if (clientTerminals !== undefined) { + const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification); + if (embedded !== undefined) { + rememberEmbeddedTerminals({ + ...embedded, + sessionId: rawNotification.sessionId, + }); + } } - } - const notification = - clientTerminals === undefined - ? rawNotification - : resolveEmbeddedTerminalContent( - rawNotification, - clientTerminals.readOutputSnapshot, - ); - if (notification.update.sessionUpdate === "available_commands_update") { - yield* ( - flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? - Effect.void - ); - } - if ( - notification.update.sessionUpdate === "config_option_update" || - notification.update.sessionUpdate === "current_mode_update" - ) { - yield* ( - flavor.onSessionConfigurationUpdate?.( - yield* runtime.getConfigOptions, - yield* runtime.getModeState, - ) ?? Effect.void + const notification = + clientTerminals === undefined + ? rawNotification + : resolveEmbeddedTerminalContent( + rawNotification, + clientTerminals.readOutputSnapshot, + ); + if (notification.update.sessionUpdate === "available_commands_update") { + yield* ( + flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? + Effect.void + ); + } + if ( + notification.update.sessionUpdate === "config_option_update" || + notification.update.sessionUpdate === "current_mode_update" + ) { + yield* ( + flavor.onSessionConfigurationUpdate?.( + yield* runtime.getConfigOptions, + yield* runtime.getModeState, + ) ?? Effect.void + ); + } + yield* handleSessionUpdate( + flavor.normalizeSessionUpdate?.(notification) ?? notification, ); - } - yield* handleSessionUpdate( - flavor.normalizeSessionUpdate?.(notification) ?? notification, - ); - }).pipe( + }, Effect.mapError( (cause) => new EffectAcpErrors.AcpTransportError({ diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.ts index 29d5f852762e..c1b322373871 100644 --- a/packages/provider-acp/src/server/mcpOverAcpBridge.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.ts @@ -139,94 +139,90 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun }).pipe(Effect.mapError(bridgeError)), ); - const disconnect = ( + const disconnect = Effect.fn("mcpOverAcpBridge.disconnect")(function* ( request: AcpSchema.DisconnectMcpRequest, - ): Effect.Effect => - Effect.gen(function* () { - const connection = yield* connectionFor(request.connectionId); - connections.delete(request.connectionId); - const sessionId = connection.sessionId; - if (sessionId !== null) { - const response = yield* Effect.tryPromise({ - try: (signal) => - fetchImplementation(options.endpoint, { - method: "DELETE", - signal, - headers: { - authorization: options.authorization, - "mcp-session-id": sessionId, - ...(connection.protocolVersion === null - ? {} - : { "mcp-protocol-version": connection.protocolVersion }), - }, - }), - catch: bridgeError, - }); - if (!response.ok && response.status !== 404) { - return yield* Effect.fail( - new AcpMcpOverAcpError( - `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, - ), - ); - } - yield* Effect.promise( - () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), + ): Effect.fn.Return { + const connection = yield* connectionFor(request.connectionId); + connections.delete(request.connectionId); + const sessionId = connection.sessionId; + if (sessionId !== null) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetchImplementation(options.endpoint, { + method: "DELETE", + signal, + headers: { + authorization: options.authorization, + "mcp-session-id": sessionId, + ...(connection.protocolVersion === null + ? {} + : { "mcp-protocol-version": connection.protocolVersion }), + }, + }), + catch: bridgeError, + }); + if (!response.ok && response.status !== 404) { + return yield* Effect.fail( + new AcpMcpOverAcpError( + `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, + ), ); } - return {}; - }); + yield* Effect.promise( + () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), + ); + } + return {}; + }); return { - connect: (request) => - Effect.gen(function* () { - if (request.serverId !== "t3-code") { - return yield* Effect.fail( - new AcpMcpOverAcpError(`Unknown ACP MCP server "${request.serverId}".`), - ); - } - if (connections.size >= MAX_CONNECTIONS) { - return yield* Effect.fail(new AcpMcpOverAcpError("Too many MCP-over-ACP connections.")); - } - const connectionId = yield* options.allocateConnectionId; - connections.set(connectionId, { - mutex: yield* Semaphore.make(1), - sessionId: null, - protocolVersion: null, - nextRequestId: 0, - }); - return { connectionId }; - }), - message: (request) => - Effect.gen(function* () { - const connection = yield* connectionFor(request.connectionId); - const id = ++connection.nextRequestId; - const payloads = yield* send(connection, { - jsonrpc: "2.0", - id, - method: request.method, - ...(request.params == null ? {} : { params: request.params }), - }); - const response = payloads.find((payload) => asEnvelope(payload)?.id === id); - const envelope = asEnvelope(response); - if (envelope === null) { - return yield* Effect.fail( - new AcpMcpOverAcpError("MCP server did not return a matching response."), - ); - } - if (envelope.error !== undefined) { - return yield* Effect.fail(new AcpMcpOverAcpError(errorMessage(envelope.error))); - } - return (envelope.result ?? null) as AcpSchema.MessageMcpResponse; - }), - notification: (request) => - Effect.gen(function* () { - const connection = yield* connectionFor(request.connectionId); - yield* send(connection, { - jsonrpc: "2.0", - method: request.method, - ...(request.params == null ? {} : { params: request.params }), - }); - }), + connect: Effect.fn("mcpOverAcpBridge.connect")(function* (request) { + if (request.serverId !== "t3-code") { + return yield* Effect.fail( + new AcpMcpOverAcpError(`Unknown ACP MCP server "${request.serverId}".`), + ); + } + if (connections.size >= MAX_CONNECTIONS) { + return yield* Effect.fail(new AcpMcpOverAcpError("Too many MCP-over-ACP connections.")); + } + const connectionId = yield* options.allocateConnectionId; + connections.set(connectionId, { + mutex: yield* Semaphore.make(1), + sessionId: null, + protocolVersion: null, + nextRequestId: 0, + }); + return { connectionId }; + }), + message: Effect.fn("mcpOverAcpBridge.message")(function* (request) { + const connection = yield* connectionFor(request.connectionId); + const id = ++connection.nextRequestId; + const payloads = yield* send(connection, { + jsonrpc: "2.0", + id, + method: request.method, + ...(request.params == null ? {} : { params: request.params }), + }); + const response = payloads.find((payload) => asEnvelope(payload)?.id === id); + const envelope = asEnvelope(response); + if (envelope === null) { + return yield* Effect.fail( + new AcpMcpOverAcpError("MCP server did not return a matching response."), + ); + } + if (envelope.error !== undefined) { + return yield* Effect.fail(new AcpMcpOverAcpError(errorMessage(envelope.error))); + } + return (envelope.result ?? null) as AcpSchema.MessageMcpResponse; + }), + notification: Effect.fn("mcpOverAcpBridge.notification")(function* (request) { + const connection = yield* connectionFor(request.connectionId); + yield* send(connection, { + jsonrpc: "2.0", + method: request.method, + ...(request.params == null ? {} : { params: request.params }), + }); + }), disconnect, dispose: Effect.suspend(() => Effect.forEach( diff --git a/packages/provider-acp/src/server/nativeLogging.ts b/packages/provider-acp/src/server/nativeLogging.ts index 48188838f2b9..b363ae57bbd1 100644 --- a/packages/provider-acp/src/server/nativeLogging.ts +++ b/packages/provider-acp/src/server/nativeLogging.ts @@ -126,11 +126,8 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" readonly threadId: ThreadId; readonly verboseProtocolLogging?: boolean; }): Pick => { - const writeNativeAcpLog = (logInput: { - readonly kind: "request" | "protocol"; - readonly payload: unknown; - }) => - Effect.gen(function* () { + const writeNativeAcpLog = Effect.fn("AcpNativeLogging.writeNativeAcpLog")( + function* (logInput: { readonly kind: "request" | "protocol"; readonly payload: unknown }) { if (!input.nativeEventLogger) return; const observedAt = DateTime.formatIso(yield* DateTime.now); yield* input.nativeEventLogger.write( @@ -147,18 +144,18 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" }, input.threadId, ); - }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.interrupt - : Effect.logWarning("Failed to write native ACP event log.", { - errorTag: causeErrorTag(cause), - reasonCount: cause.reasons.length, - provider: input.provider, - threadId: input.threadId, - }), - ), - ); + }, + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to write native ACP event log.", { + errorTag: causeErrorTag(cause), + reasonCount: cause.reasons.length, + provider: input.provider, + threadId: input.threadId, + }), + ), + ); return { requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index a2a271e5b9b9..bad434e938e0 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1287,27 +1287,24 @@ export interface SessionLoadGate { readonly candidateConfigOptions?: ReadonlyArray; } -export const waitForSessionLoadReplayIdle = (input: { +export const waitForSessionLoadReplayIdle = Effect.fn( + "AcpRuntimeModel.waitForSessionLoadReplayIdle", +)(function* (input: { readonly gateRef: Ref.Ref>; -}): Effect.Effect => - Effect.gen(function* () { - const pollInterval = Duration.millis(25); - while (true) { - const gate = yield* Ref.get(input.gateRef); - if ( - Option.isSome(gate) && - gate.value.active && - gate.value.lastActivityAtMillis !== undefined - ) { - const idleGapMillis = Duration.toMillis(gate.value.idleGap); - const nowMillis = yield* Clock.currentTimeMillis; - if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { - return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); - } +}): Effect.fn.Return { + const pollInterval = Duration.millis(25); + while (true) { + const gate = yield* Ref.get(input.gateRef); + if (Option.isSome(gate) && gate.value.active && gate.value.lastActivityAtMillis !== undefined) { + const idleGapMillis = Duration.toMillis(gate.value.idleGap); + const nowMillis = yield* Clock.currentTimeMillis; + if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { + return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); } - yield* Effect.sleep(pollInterval); } - }); + yield* Effect.sleep(pollInterval); + } +}); export function sessionModelStateFromInitialize( initializeResult: EffectAcpSchema.InitializeResponse, diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index 432bf60f94cb..85eed312f71c 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -176,8 +176,8 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi let completed = false; const pageSize = 1000; let total: number | undefined; - const readPage = (page: number) => - Effect.gen(function* () { + const readPage = Effect.fn("CursorAccountUsage.readPage")( + function* (page: number) { // A count can include overlapping page boundaries. Allow room to // reconcile them without imposing a fixed account-size limit. if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { @@ -243,12 +243,12 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi } if (typeof count === "number") total = count; return events as unknown[]; - }).pipe( - Effect.timeoutOrElse({ - duration: Duration.seconds(10), - orElse: () => invalid("Account usage request timed out"), - }), - ); + }, + Effect.timeoutOrElse({ + duration: Duration.seconds(10), + orElse: () => invalid("Account usage request timed out"), + }), + ); // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages // the first one's count implies are requested up to six ahead and consumed in page order. const ahead: Fiber.Fiber< diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts index 198bbeb37279..2122731a9d76 100644 --- a/packages/provider-cursor/src/server/auth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -154,8 +154,8 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu }), ); - const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect) => - Effect.gen(function* () { + const runLogin = Effect.fn("CursorAuth.runLogin")( + function* (flow: AuthFlow, stopSessions: Effect.Effect) { yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); // The SDK first writes into memory. Cancellation must not save a late login result. const pendingStore = new InMemoryCredentialStore(); @@ -243,50 +243,56 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu }); }).pipe(Effect.uninterruptible), ); - }).pipe( - Effect.scoped, - Effect.timeoutOrElse({ - duration: AUTH_TIMEOUT_MS, - orElse: () => - Effect.fail( - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Cursor sign-in expired. Start sign-in again.", + }, + (effect, flow, stopSessions) => + effect.pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: AUTH_TIMEOUT_MS, + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Cursor sign-in expired. Start sign-in again.", + }), + ), + }), + Effect.exit, + Effect.flatMap((result) => + lock.withPermits(1)( + Effect.gen(function* () { + if (active !== flow) return; + active = undefined; + operation = "idle"; + yield* publish(flow, { + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? "Signed in with Cursor." + : "Cursor sign-in failed or expired. Start sign-in again.", + }); }), ), - }), - Effect.exit, - Effect.flatMap((result) => - lock.withPermits(1)( - Effect.gen(function* () { - if (active !== flow) return; - active = undefined; - operation = "idle"; - yield* publish(flow, { - phase: Exit.isSuccess(result) ? "succeeded" : "failed", - authorizationUrl: null, - expiresAt: null, - message: Exit.isSuccess(result) - ? "Signed in with Cursor." - : "Cursor sign-in failed or expired. Start sign-in again.", - }); - }), ), ), - ); + ); - const requireFlow = (owner: string, id: string, name: string) => - Effect.gen(function* () { - if (!active || active.owner !== owner || active.id !== id) { - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: name, - detail: "This sign-in is no longer active in this client.", - }); - } - return active; - }); + const requireFlow = Effect.fn("CursorAuth.requireFlow")(function* ( + owner: string, + id: string, + name: string, + ) { + if (!active || active.owner !== owner || active.id !== id) { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: name, + detail: "This sign-in is no longer active in this client.", + }); + } + return active; + }); const controller: ProviderAuthController = { credentialBinding: options.credentialBinding ?? { diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 5a3ac63166fc..6937c7aa88a7 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -86,224 +86,230 @@ export const CursorDriver: ProviderDriver< ), ), }, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const host = yield* ProviderHost.ProviderHost; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; - const keychain = yield* CursorKeychain.CursorKeychain; - const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName: displayName ?? "Cursor", - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies CursorSettings; - const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( - instanceId, - path.join( - host.paths.stateDir, - "provider-auth", - encodeURIComponent(instanceId), - "cursor.json", - ), - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Could not open the Cursor credential store.", - cause, - }), - ), - ); - const auth = yield* makeCursorAuth({ - instanceId, - displayName: displayName ?? "Cursor", - enabled, - ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}), - store: credentials.store, - credentialBinding: credentials.binding, - onChanged: (signedIn): Effect.Effect => - snapshot.refresh.pipe( - Effect.flatMap((provider) => - !signedIn || provider.auth.status === "authenticated" - ? Effect.void - : Effect.fail( - new ProviderSetupError({ - instanceId, - operation: "start", - detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.", - }), - ), - ), + create: Effect.fn("CursorDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const host = yield* ProviderHost.ProviderHost; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const keychain = yield* CursorKeychain.CursorKeychain; + const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName: displayName ?? "Cursor", + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies CursorSettings; + const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( + instanceId, + path.join( + host.paths.stateDir, + "provider-auth", + encodeURIComponent(instanceId), + "cursor.json", + ), + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Could not open the Cursor credential store.", + cause, + }), + ), + ); + const auth = yield* makeCursorAuth({ + instanceId, + displayName: displayName ?? "Cursor", + enabled, + ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}), + store: credentials.store, + credentialBinding: credentials.binding, + onChanged: (signedIn): Effect.Effect => + snapshot.refresh.pipe( + Effect.flatMap((provider) => + !signedIn || provider.auth.status === "authenticated" + ? Effect.void + : Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "start", + detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.", + }), + ), ), + ), + }); + const stampSnapshot: typeof stampIdentity = (draft) => + stampIdentity({ + ...draft, + setup: { canAuthenticate: !auth.usesApiKey, canInstall: false }, + auth: { + ...draft.auth, + canLogout: !auth.usesApiKey, + }, }); - const stampSnapshot: typeof stampIdentity = (draft) => - stampIdentity({ - ...draft, - setup: { canAuthenticate: !auth.usesApiKey, canInstall: false }, - auth: { - ...draft.auth, - canLogout: !auth.usesApiKey, - }, - }); - let catalogModels: ReadonlyArray = []; - const orchestrationAdapter = yield* makeCursorAdapterV2({ - instanceId, - settings: effectiveConfig, - environment: processEnv, - getModels: () => catalogModels, - }).pipe( - Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { - ...sdkRunner, - open: (input) => - auth.requireApiKey.pipe( - Effect.flatMap((apiKey) => - Effect.acquireRelease( - sdkRunner - .open({ ...input, options: { ...input.options, apiKey } }) - .pipe( - Effect.flatMap((session) => - Effect.cached(session.close).pipe( - Effect.map((close) => ({ ...session, close })), - ), + let catalogModels: ReadonlyArray = []; + const orchestrationAdapter = yield* makeCursorAdapterV2({ + instanceId, + settings: effectiveConfig, + environment: processEnv, + getModels: () => catalogModels, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + ...sdkRunner, + open: (input) => + auth.requireApiKey.pipe( + Effect.flatMap((apiKey) => + Effect.acquireRelease( + sdkRunner + .open({ ...input, options: { ...input.options, apiKey } }) + .pipe( + Effect.flatMap((session) => + Effect.cached(session.close).pipe( + Effect.map((close) => ({ ...session, close })), ), ), - (session) => session.close.pipe(Effect.ignore), - ), - ), - auth.withAccess, - Effect.mapError((cause) => - isSdkRunnerError(cause) - ? cause - : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), + ), + (session) => session.close.pipe(Effect.ignore), ), ), - }), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Cursor orchestration adapter.", - cause, - }), - ), - ); - const textGeneration = yield* makeCursorTextGeneration( - effectiveConfig, - processEnv, - auth.requireApiKey, - auth.withAccess, - ); - - const checkProvider = auth.readApiKey.pipe( - Effect.orElseSucceed(() => undefined), - Effect.flatMap((apiKey) => - checkCursorProviderStatus( - effectiveConfig, - { - ...processEnv, - CURSOR_API_KEY: apiKey, - }, - auth.usesApiKey ? "api-key" : "browser", - ).pipe( - Effect.flatMap((snapshot) => - effectiveConfig.enabled && - snapshot.installed && - snapshot.auth.status === "authenticated" - ? host.settings.get.pipe( - Effect.flatMap((settings) => - readCursorUsageLimits( - effectiveConfig, - { ...processEnv, CURSOR_API_KEY: apiKey }, - settings.cursorKeychainUsageEnabled, - ), - ), - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ) - : Effect.succeed(snapshot), + auth.withAccess, + Effect.mapError((cause) => + isSdkRunnerError(cause) + ? cause + : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), ), ), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(CursorKeychain.CursorKeychain, keychain), - Effect.map(stampSnapshot), - Effect.tap((provider) => - Effect.sync(() => { - // A failed check lists only custom models, so keep the last loaded catalog. - if (provider.models.some((model) => !model.isCustom)) { - catalogModels = provider.models; - } + }), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Cursor orchestration adapter.", + cause, }), - ), - Effect.provide(CursorSdkCatalog.layer), - ); + ), + ); + const textGeneration = yield* makeCursorTextGeneration( + effectiveConfig, + processEnv, + auth.requireApiKey, + auth.withAccess, + ); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)), - checkProvider, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Cursor snapshot.", - cause, - }), + const checkProvider = auth.readApiKey.pipe( + Effect.orElseSucceed(() => undefined), + Effect.flatMap((apiKey) => + checkCursorProviderStatus( + effectiveConfig, + { + ...processEnv, + CURSOR_API_KEY: apiKey, + }, + auth.usesApiKey ? "api-key" : "browser", + ).pipe( + Effect.flatMap((snapshot) => + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ? host.settings.get.pipe( + Effect.flatMap((settings) => + readCursorUsageLimits( + effectiveConfig, + { ...processEnv, CURSOR_API_KEY: apiKey }, + settings.cursorKeychainUsageEnabled, + ), + ), + Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), + ) + : Effect.succeed(snapshot), + ), ), - ); + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(CursorKeychain.CursorKeychain, keychain), + Effect.map(stampSnapshot), + Effect.tap((provider) => + Effect.sync(() => { + // A failed check lists only custom models, so keep the last loaded catalog. + if (provider.models.some((model) => !model.isCustom)) { + catalogModels = provider.models; + } + }), + ), + Effect.provide(CursorSdkCatalog.layer), + ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - auth: auth.controller, - snapshot, - snapshotForCwd: (cwd) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - probeCursorSkills(cwd, processEnv).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to discover Cursor skills for '${cwd}'`, - cause, - }), - ), + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Cursor snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + auth: auth.controller, + snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeCursorSkills(cwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Cursor skills for '${cwd}'`, + cause, + }), ), - ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), }; diff --git a/packages/provider-cursor/src/server/status.ts b/packages/provider-cursor/src/server/status.ts index 3b00118688df..2c21a01459b0 100644 --- a/packages/provider-cursor/src/server/status.ts +++ b/packages/provider-cursor/src/server/status.ts @@ -34,44 +34,42 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; -export function buildInitialCursorProviderSnapshot( - cursorSettings: CursorSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); - } +export const buildInitialCursorProviderSnapshot = Effect.fn( + "CursorProviderStatus.buildInitialCursorProviderSnapshot", +)(function* (cursorSettings: CursorSettings): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getCursorFallbackModels(cursorSettings); + if (!cursorSettings.enabled) { return buildServerProvider({ presentation: CURSOR_PRESENTATION, - enabled: true, + enabled: false, checkedAt, models, probe: { - installed: true, + installed: false, version: null, status: "warning", auth: { status: "unknown" }, - message: "Checking Cursor SDK availability...", + message: "Cursor is disabled in T3 Code settings.", }, }); + } + + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Cursor SDK availability...", + }, }); -} +}); function getCursorFallbackModels( cursorSettings: Pick, diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index 1797e5d38522..cd6c1e511f58 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -57,31 +57,35 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const { Agent, SqliteLocalAgentStore } = yield* CursorSdk.CursorSdk; const resolvedEnvironment = environment ?? process.env; - const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => - Effect.gen(function* () { - if (!cursorSettings.enabled) { - return yield* new TextGenerationError({ - operation, - detail: "Cursor is disabled in T3 Code settings.", - }); - } + const resolveCursorApiKey = Effect.fn("CursorTextGeneration.resolveCursorApiKey")(function* ( + operation: TextGenerationOperations.Operation, + ) { + if (!cursorSettings.enabled) { + return yield* new TextGenerationError({ + operation, + detail: "Cursor is disabled in T3 Code settings.", + }); + } - const apiKey = resolveApiKey - ? yield* resolveApiKey - : resolvedEnvironment.CURSOR_API_KEY?.trim(); - if (!apiKey) { - return yield* new TextGenerationError({ - operation, - detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", - }); - } + const apiKey = resolveApiKey + ? yield* resolveApiKey + : resolvedEnvironment.CURSOR_API_KEY?.trim(); + if (!apiKey) { + return yield* new TextGenerationError({ + operation, + detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", + }); + } + + return apiKey; + }); - return apiKey; - }); + const runCursorJson: TextGenerationOperations.Runner = Effect.fn( + "CursorTextGeneration.runCursorJson", + )( + function* (input) { + const { operation, prompt, modelSelection } = input; - const runCursorJson: TextGenerationOperations.Runner = (input) => { - const { operation, prompt, modelSelection } = input; - return Effect.gen(function* () { const apiKey = yield* resolveCursorApiKey(operation); // The SDK loads sandbox.json independently of settingSources and lets it // expand the writable paths. Its public API cannot override that policy. @@ -187,22 +191,25 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult); - }).pipe( - // Scope this call inside `withAccess`, so sign-out closes the agent before its store. - Effect.scoped, - (effect) => (withAccess ? withAccess(effect) : effect), - Effect.scoped, - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Cursor SDK text generation failed.", - cause, - }), - ), - ); - }; + }, + (effect, input) => { + const { operation } = input; + return effect.pipe( + Effect.scoped, + (effect) => (withAccess ? withAccess(effect) : effect), + Effect.scoped, + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Cursor SDK text generation failed.", + cause, + }), + ), + ); + }, + ); return TextGenerationOperations.fromRunner( "CursorTextGeneration", diff --git a/packages/provider-grok/src/server/acpSupport.ts b/packages/provider-grok/src/server/acpSupport.ts index 726e28cd6ff6..6ed3e6383abc 100644 --- a/packages/provider-grok/src/server/acpSupport.ts +++ b/packages/provider-grok/src/server/acpSupport.ts @@ -145,37 +145,34 @@ export function grokApprovalOptions( ]; } -export const makeGrokAcpRuntime = ( +export const makeGrokAcpRuntime = Effect.fn("GrokAcpSupport.makeGrokAcpRuntime")(function* ( input: GrokAcpRuntimeInput, -): Effect.Effect< +): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope -> => - Effect.gen(function* () { - const processGroupPlatform = yield* HostProcess.Platform.pipe( - Effect.provide(NodeServices.layer), - ); - const acpContext = yield* Layer.build( - AcpSessionRuntime.layer({ - ...input, - spawn: buildGrokAcpSpawnInput( - input.grokSettings, - input.cwd, - input.environment, - input.runtimeMode, - ), - authMethodId: resolveGrokAuthMethodId(input.environment), - cancelMeta: { ...input.cancelMeta, ...GROK_ACP_CANCEL_META }, - initializeMeta: GROK_ACP_INITIALIZE_META, - ...grokAcpRuntimeProcessOwnership(processGroupPlatform), - }), - ); - const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( - Effect.provide(acpContext), - ); - return yield* makeXAiPromptCompletionRuntime(runtime); - }); +> { + const processGroupPlatform = yield* HostProcess.Platform.pipe(Effect.provide(NodeServices.layer)); + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildGrokAcpSpawnInput( + input.grokSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), + authMethodId: resolveGrokAuthMethodId(input.environment), + cancelMeta: { ...input.cancelMeta, ...GROK_ACP_CANCEL_META }, + initializeMeta: GROK_ACP_INITIALIZE_META, + ...grokAcpRuntimeProcessOwnership(processGroupPlatform), + }), + ); + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + return yield* makeXAiPromptCompletionRuntime(runtime); +}); /** * It is the CLI's product name, not a model id the ACP accepts, so selecting it diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index ea8c3b101c51..6b7ec52ed8e7 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -264,22 +264,25 @@ export function makeGrokAcpAdapterFlavor( ? window : undefined; }, - applyModelSelection: ({ runtime, startResult, modelSelection }) => - Effect.gen(function* () { - const legacy = startResult.initializeResult.protocolVersion === 1; - const options = legacy ? [] : yield* runtime.getConfigOptions; - const configuredModel = findModelConfigOption(options)?.currentValue; - return yield* applyGrokAcpModelSelection({ - runtime: legacy - ? runtime - : { setSessionModel: (model) => runtime.setModel(model).pipe(Effect.as({})) }, - currentModelId: legacy - ? currentGrokModelIdFromSessionSetup(startResult.sessionSetupResult) - : configuredModel, - requestedModelId: resolveGrokAcpBaseModelId(modelSelection.model), - mapError: (cause) => cause, - }); - }), + applyModelSelection: Effect.fn("GrokAdapter.applyModelSelection")(function* ({ + runtime, + startResult, + modelSelection, + }) { + const legacy = startResult.initializeResult.protocolVersion === 1; + const options = legacy ? [] : yield* runtime.getConfigOptions; + const configuredModel = findModelConfigOption(options)?.currentValue; + return yield* applyGrokAcpModelSelection({ + runtime: legacy + ? runtime + : { setSessionModel: (model) => runtime.setModel(model).pipe(Effect.as({})) }, + currentModelId: legacy + ? currentGrokModelIdFromSessionSetup(startResult.sessionSetupResult) + : configuredModel, + requestedModelId: resolveGrokAcpBaseModelId(modelSelection.model), + mapError: (cause) => cause, + }); + }), makeRuntime: options.makeRuntime ?? (({ runtimePolicy, ...input }) => diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index 4b7fb8de3b7d..0c9cfe62f18c 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -111,142 +111,148 @@ export const GrokDriver: ProviderDriver configSchema: GrokSettings, defaultConfig: (): GrokSettings => decodeGrokSettings({}), usage: grokUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; - const { cwd } = host.paths; - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies GrokSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ); - const orchestrationAdapter = yield* GrokAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, + create: Effect.fn("GrokDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* ProviderHost.ProviderHost; + const { cwd } = host.paths; + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies GrokSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Grok orchestration adapter.", - cause, - }), - ), - ); - const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( - Effect.flatMap((snapshot) => - effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? readGrokAccount(processEnv).pipe( - Effect.map(({ email, usageLimits }) => ({ - ...snapshot, - auth: email ? { ...snapshot.auth, email } : snapshot.auth, - usageLimits, - })), - ) - : Effect.succeed(snapshot), - ), - Effect.map(stampIdentity), - Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + ), + ); + const orchestrationAdapter = yield* GrokAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Grok orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichGrokSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Grok snapshot.", - cause, + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.flatMap((snapshot) => + effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" + ? readGrokAccount(processEnv).pipe( + Effect.map(({ email, usageLimits }) => ({ + ...snapshot, + auth: email ? { ...snapshot.auth, email } : snapshot.auth, + usageLimits, + })), + ) + : Effect.succeed(snapshot), + ), + Effect.map(stampIdentity), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichGrokSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), ), - ); - const snapshotForCwd = (workspaceCwd: string) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - discoverGrokSkills(effectiveConfig, processEnv, workspaceCwd).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to discover Grok skills for '${workspaceCwd}'`, - cause, - }), - ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Grok snapshot.", + cause, + }), + ), + ); + const snapshotForCwd = (workspaceCwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverGrokSkills(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Grok skills for '${workspaceCwd}'`, + cause, + }), ), - ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))); + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - snapshotForCwd, - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), }; diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 65322c04a38f..825f8080599a 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -71,44 +71,42 @@ const GROK_BUILT_IN_MODELS: ReadonlyArray = [ }, ]; -export function buildInitialGrokProviderSnapshot( - grokSettings: GrokSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = grokModelsFromSettings(grokSettings.customModels); - - if (!grokSettings.enabled) { - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Grok is disabled in T3 Code settings.", - }, - }); - } +export const buildInitialGrokProviderSnapshot = Effect.fn( + "GrokProviderStatus.buildInitialGrokProviderSnapshot", +)(function* (grokSettings: GrokSettings): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = grokModelsFromSettings(grokSettings.customModels); + if (!grokSettings.enabled) { return buildServerProvider({ presentation: GROK_PRESENTATION, - enabled: true, + enabled: false, checkedAt, models, probe: { - installed: true, + installed: false, version: null, status: "warning", auth: { status: "unknown" }, - message: "Checking Grok CLI availability...", + message: "Grok is disabled in T3 Code settings.", }, }); + } + + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Grok CLI availability...", + }, }); -} +}); function grokModelsFromSettings( customModels: ReadonlyArray | undefined, @@ -287,22 +285,21 @@ function displayNameFromGrokModelSlug(slug: string): string { .join(" "); } -const runGrokCliCommand = ( +const runGrokCliCommand = Effect.fn("GrokProviderStatus.runGrokCliCommand")(function* ( grokSettings: GrokSettings, args: ReadonlyArray, environment: NodeJS.ProcessEnv, -) => - Effect.gen(function* () { - const command = grokSettings.binaryPath || "grok"; - const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); - return yield* spawnAndCollect( - command, - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: environment, - shell: spawnCommand.shell, - }), - ); - }); +) { + const command = grokSettings.binaryPath || "grok"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); const decodeAvailableCommand = Schema.decodeUnknownOption( @@ -343,23 +340,21 @@ export function grokSlashCommandsFromInitialize( * This never calls `authenticate` or `session/new`, so it cannot open a browser login or * boot the workspace's MCP servers. */ -const discoverGrokMetadataViaAcpInitialize = ( - grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv, -) => - Effect.gen(function* () { - const acp = yield* makeGrokAcpRuntime({ - grokSettings, - environment, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, - }); - const initialized = yield* acp.initialize(); - return { - models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)), - slashCommands: grokSlashCommandsFromInitialize(initialized), - }; - }).pipe(Effect.scoped); +const discoverGrokMetadataViaAcpInitialize = Effect.fn( + "GrokProviderStatus.discoverGrokMetadataViaAcpInitialize", +)(function* (grokSettings: GrokSettings, environment: NodeJS.ProcessEnv) { + const acp = yield* makeGrokAcpRuntime({ + grokSettings, + environment, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const initialized = yield* acp.initialize(); + return { + models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)), + slashCommands: grokSlashCommandsFromInitialize(initialized), + }; +}, Effect.scoped); export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, diff --git a/packages/provider-grok/src/server/textGeneration.ts b/packages/provider-grok/src/server/textGeneration.ts index 7edf34963aec..9907ad144135 100644 --- a/packages/provider-grok/src/server/textGeneration.ts +++ b/packages/provider-grok/src/server/textGeneration.ts @@ -30,9 +30,10 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runGrokJson: TextGenerationOperations.Runner = (request) => { - const { operation, cwd, prompt, modelSelection } = request; - return Effect.gen(function* () { + const runGrokJson: TextGenerationOperations.Runner = Effect.fn("GrokTextGeneration.runGrokJson")( + function* (request) { + const { operation, cwd, prompt, modelSelection } = request; + const outputRef = yield* Ref.make(""); const runtime = yield* makeGrokAcpRuntime({ grokSettings, @@ -115,19 +116,23 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } return yield* TextGenerationOperations.decodeJsonReply(request, "Grok Agent", trimmed); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Grok ACP text generation failed.", - cause, - }), - ), - Effect.scoped, - ); - }; + }, + (effect, request) => { + const { operation } = request; + return effect.pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Grok ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + }, + ); return TextGenerationOperations.fromRunner( "GrokTextGeneration", diff --git a/packages/provider-grok/src/server/xaiAcpExtension.ts b/packages/provider-grok/src/server/xaiAcpExtension.ts index c392b88cb0db..56fe64142d67 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.ts @@ -1536,58 +1536,57 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion ); }) : runtime.handleExtNotification(method, payload, handler), - prompt: (payload, promptOptions?) => - Effect.gen(function* () { - const started = yield* runtime.start(); - const promptId = yield* allocatePromptFallbackId; - const fallback = yield* registerXAiPromptCompletionFallback( - pendingXAiPromptCompletionsRef, - started.sessionId, - promptId, - ); - const cancelledResponse = promptResponseFromXAi({ - sessionId: started.sessionId, - promptId: fallback.promptId, - stopReason: "cancelled", - agentResult: null, - }); - const promptRpcFiber = yield* runtime - .prompt( - { - ...payload, - _meta: { - ...payload._meta, - promptId: fallback.promptId, - requestId: fallback.promptId, - }, + prompt: Effect.fn("xaiAcpExtension.prompt")(function* (payload, promptOptions?) { + const started = yield* runtime.start(); + const promptId = yield* allocatePromptFallbackId; + const fallback = yield* registerXAiPromptCompletionFallback( + pendingXAiPromptCompletionsRef, + started.sessionId, + promptId, + ); + const cancelledResponse = promptResponseFromXAi({ + sessionId: started.sessionId, + promptId: fallback.promptId, + stopReason: "cancelled", + agentResult: null, + }); + const promptRpcFiber = yield* runtime + .prompt( + { + ...payload, + _meta: { + ...payload._meta, + promptId: fallback.promptId, + requestId: fallback.promptId, }, - promptOptions, - ) - .pipe(Effect.forkChild); - return yield* Effect.raceFirst( - Fiber.join(promptRpcFiber).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.succeed(cancelledResponse) - : Effect.failCause(cause), - ), - ), - Deferred.await(fallback.deferred), - ).pipe( - Effect.tap((response) => - rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId), + }, + promptOptions, + ) + .pipe(Effect.forkChild); + return yield* Effect.raceFirst( + Fiber.join(promptRpcFiber).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), ), - Effect.ensuring( - Effect.gen(function* () { - yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* unregisterXAiPromptCompletionFallback( - pendingXAiPromptCompletionsRef, - fallback.deferred, - ); - }), - ), - ); - }), + ), + Deferred.await(fallback.deferred), + ).pipe( + Effect.tap((response) => + rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId), + ), + Effect.ensuring( + Effect.gen(function* () { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + yield* unregisterXAiPromptCompletionFallback( + pendingXAiPromptCompletionsRef, + fallback.deferred, + ); + }), + ), + ); + }), cancel: Effect.gen(function* () { // Do not call start here: cancellation must be able to reach the // underlying runtime when startup is stalled. diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index d762c85d976a..202c68089c75 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -2141,8 +2141,8 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( ), ), compactThread: (args) => start(args, true).pipe(commands.withPermits(1)), - steerTurn: (args) => - Effect.gen(function* () { + steerTurn: Effect.fn("MuseAdapter.steerTurn")( + function* (args) { const turn = active; if ( !turn || @@ -2158,20 +2158,23 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value))); if (result.turnId !== turn.nativeId) return yield* protocolError("Muse steering targeted a different turn"); - }).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterSteerRunError({ - driver: MUSE_PROVIDER, - providerThreadId: args.providerThread.id, - providerTurnId: args.providerTurnId, - cause, - }), + }, + (effect, args) => + effect.pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver: MUSE_PROVIDER, + providerThreadId: args.providerThread.id, + providerTurnId: args.providerTurnId, + cause, + }), + ), ), - ), - interruptTurn: (args) => - Effect.gen(function* () { + ), + interruptTurn: Effect.fn("MuseAdapter.interruptTurn")( + function* (args) { const turn = active; if ( !turn || @@ -2207,20 +2210,23 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( Effect.mapError((cause) => protocolError("Muse did not confirm interruption", cause)), Effect.onError((cause) => eventPermit.withPermits(1)(failHost(cause))), ); - }).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: MUSE_PROVIDER, - providerThreadId: args.providerThread.id, - providerTurnId: args.providerTurnId, - cause, - }), + }, + (effect, args) => + effect.pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: MUSE_PROVIDER, + providerThreadId: args.providerThread.id, + providerTurnId: args.providerTurnId, + cause, + }), + ), ), - ), - respondToRuntimeRequest: (args) => - Effect.gen(function* () { + ), + respondToRuntimeRequest: Effect.fn("MuseAdapter.respondToRuntimeRequest")( + function* (args) { const entry = pending.get(args.requestId); if (!entry) return yield* protocolError("This Muse request is no longer pending"); if (entry.native.type === "approval") yield* decideApproval(entry, args.decision); @@ -2261,16 +2267,19 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( answers, }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); } - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver: MUSE_PROVIDER, - requestId: args.requestId, - cause, - }), + }, + (effect, args) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: MUSE_PROVIDER, + requestId: args.requestId, + cause, + }), + ), ), - ), + ), readThreadSnapshot: (args) => (!validateThread(args.providerThread) ? protocolError("Snapshot requested for a different Muse thread") diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index bf0024a00979..9a39487a5a2f 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -58,136 +58,140 @@ export const MuseDriver: ProviderDriver = { metadata: { displayName: "Muse Code", supportsMultipleInstances: true }, configSchema: MuseSettings, defaultConfig: () => decodeMuseSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const host = yield* ProviderHost.ProviderHost; - const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const { cwd } = host.paths; - const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const hostEnvironment = yield* HostProcess.Environment; - // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins. - const processEnvironment = yield* mergeProviderInstanceEnvironment( - environment, - makeMuseEnvironment(hostEnvironment), - ); - const effectiveConfig = { - ...config, - enabled, - binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), - } satisfies MuseSettings; - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { - binaryPath: effectiveConfig.binaryPath, - env: processEnvironment, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ); - const resolveMaintenance = (options?: { readonly fresh?: boolean }) => - Effect.gen(function* () { - const capabilities = yield* resolveInstallation(options); - // The maintenance runner requests fresh capabilities around an explicit update - // and needs the native target version to verify that the command actually upgraded. - const latestVersion = options?.fresh - ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService( - ProviderLatestVersions.ProviderLatestVersions, - latestVersions, - ), - ) - : undefined; - return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; - }); - const snapshot = yield* makeManagedServerProvider>({ - // Status refresh respects update checks; explicit updates use the resolver below. - resolveMaintenance: (options) => - host.settings.get.pipe( - Effect.orDie, - Effect.flatMap((settings) => - settings.enableProviderUpdateChecks - ? resolveMaintenance(options) - : resolveInstallation(options), - ), - ), - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe( - Effect.map(stampIdentity), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichMuseSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - environment: processEnvironment, - }), - ), + create: Effect.fn("MuseDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const host = yield* ProviderHost.ProviderHost; + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const { cwd } = host.paths; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const hostEnvironment = yield* HostProcess.Environment; + // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins. + const processEnvironment = yield* mergeProviderInstanceEnvironment( + environment, + makeMuseEnvironment(hostEnvironment), + ); + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), + } satisfies MuseSettings; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { + binaryPath: effectiveConfig.binaryPath, + env: processEnvironment, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const resolveMaintenance = Effect.fn("MuseDriver.resolveMaintenance")(function* (options?: { + readonly fresh?: boolean; + }) { + const capabilities = yield* resolveInstallation(options); + // The maintenance runner requests fresh capabilities around an explicit update + // and needs the native target version to verify that the command actually upgraded. + const latestVersion = options?.fresh + ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - Effect.flatMap(publishSnapshot), + ) + : undefined; + return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; + }); + const snapshot = yield* makeManagedServerProvider>({ + // Status refresh respects update checks; explicit updates use the resolver below. + resolveMaintenance: (options) => + host.settings.get.pipe( + Effect.orDie, + Effect.flatMap((settings) => + settings.enableProviderUpdateChecks + ? resolveMaintenance(options) + : resolveInstallation(options), ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Muse Code snapshot.", - cause, + ), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichMuseSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + environment: processEnvironment, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + Effect.flatMap(publishSnapshot), ), - ); - const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models)); - const orchestrationAdapter = yield* makeMuseAdapterV2({ - instanceId, - settings: effectiveConfig, - environment: processEnvironment, - modelCatalog, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - continuationRequests, - }); - const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, { - environment: processEnvironment, - modelCatalog, - }); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot: { ...snapshot, resolveMaintenance }, - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Muse Code snapshot.", + cause, + }), + ), + ); + const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models)); + const orchestrationAdapter = yield* makeMuseAdapterV2({ + instanceId, + settings: effectiveConfig, + environment: processEnvironment, + modelCatalog, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + continuationRequests, + }); + const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, { + environment: processEnvironment, + modelCatalog, + }); + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: { ...snapshot, resolveMaintenance }, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), }; diff --git a/packages/provider-muse/src/server/maintenance.ts b/packages/provider-muse/src/server/maintenance.ts index 8295fedd9083..0a3a59de128e 100644 --- a/packages/provider-muse/src/server/maintenance.ts +++ b/packages/provider-muse/src/server/maintenance.ts @@ -39,47 +39,46 @@ export const compareMuseVersions = (current: string, latest: string) => /** Only the official launcher owns an update channel; a standalone binary cannot update itself. */ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { - resolve: (context) => - Effect.gen(function* () { - const manual = { - ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }), - compareVersions: compareMuseVersions, - }; - if (!context || context.platform === "win32") return manual; - const fs = yield* FileSystem.FileSystem; - const isLauncher = yield* Effect.gen(function* () { - const stat = yield* fs.stat(context.realCommandPath); - if (Number(stat.size) > 128 * 1_024) return false; - const source = yield* fs.readFileString(context.realCommandPath); - return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE"); - }).pipe(Effect.orElseSucceed(() => false)); - if (!isLauncher) return manual; - // The launcher has no `update` subcommand. Its documented launch environment - // forces a synchronous refresh of this executable's own installation. - const updateEnv = { - ...(context.env.MUSE_CHANNEL ? { MUSE_CHANNEL: context.env.MUSE_CHANNEL } : {}), - MUSE_NO_AUTO_UPDATE: "0", - MUSE_SYNC_UPDATE: "1", - MUSE_UPDATE_INTERVAL_SECONDS: "0", - }; - return { - ...makeProviderMaintenanceCapabilities({ - provider: DRIVER, - packageName: null, - updateExecutable: context.resolvedCommandPath, - updateArgs: ["--version"], - updateCommand: [ - ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), - shellQuote(context.resolvedCommandPath), - "--version", - ].join(" "), - updateLockKey: `muse:${context.realCommandPath}`, - platform: context.platform, - env: { ...context.env, ...updateEnv }, - }), - compareVersions: compareMuseVersions, - }; - }), + resolve: Effect.fn("MuseMaintenance.resolve")(function* (context) { + const manual = { + ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }), + compareVersions: compareMuseVersions, + }; + if (!context || context.platform === "win32") return manual; + const fs = yield* FileSystem.FileSystem; + const isLauncher = yield* Effect.gen(function* () { + const stat = yield* fs.stat(context.realCommandPath); + if (Number(stat.size) > 128 * 1_024) return false; + const source = yield* fs.readFileString(context.realCommandPath); + return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE"); + }).pipe(Effect.orElseSucceed(() => false)); + if (!isLauncher) return manual; + // The launcher has no `update` subcommand. Its documented launch environment + // forces a synchronous refresh of this executable's own installation. + const updateEnv = { + ...(context.env.MUSE_CHANNEL ? { MUSE_CHANNEL: context.env.MUSE_CHANNEL } : {}), + MUSE_NO_AUTO_UPDATE: "0", + MUSE_SYNC_UPDATE: "1", + MUSE_UPDATE_INTERVAL_SECONDS: "0", + }; + return { + ...makeProviderMaintenanceCapabilities({ + provider: DRIVER, + packageName: null, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["--version"], + updateCommand: [ + ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), + shellQuote(context.resolvedCommandPath), + "--version", + ].join(" "), + updateLockKey: `muse:${context.realCommandPath}`, + platform: context.platform, + env: { ...context.env, ...updateEnv }, + }), + compareVersions: compareMuseVersions, + }; + }), }; function shellQuote(word: string) { diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index bff49126ce4d..bd17cb81bcb9 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -604,8 +604,10 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => resolveSpawnCommand(command, args, { ...(env ? { env } : {}), bypassNpmShim: true }); - const runOpenCodeCommand: OpenCodeRuntime["Service"]["runOpenCodeCommand"] = (input) => - Effect.gen(function* () { + const runOpenCodeCommand: OpenCodeRuntime["Service"]["runOpenCodeCommand"] = Effect.fn( + "OpenCodeRuntime.runOpenCodeCommand", + )( + function* (input) { const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -646,16 +648,19 @@ const makeOpenCodeRuntime = Effect.gen(function* () { stderr, code: exitCode, } satisfies OpenCodeCommandResult; - }).pipe( - Effect.scoped, - Effect.mapError((cause) => - ensureRuntimeError( - "runOpenCodeCommand", - `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, - cause, + }, + (effect, input) => + effect.pipe( + Effect.scoped, + Effect.mapError((cause) => + ensureRuntimeError( + "runOpenCodeCommand", + `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ), ), ), - ); + ); const createOpenCodeSdkClient: OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] = (input) => createOpencodeClient({ @@ -671,220 +676,219 @@ const makeOpenCodeRuntime = Effect.gen(function* () { throwOnError: true, }); - const startServerAttempt: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = (input) => - Effect.gen(function* () { - const runtimeScope = yield* Scope.Scope; - - const hostname = input.hostname ?? DEFAULT_HOSTNAME; - const port = - input.port ?? - (yield* netService.findAvailablePort(0).pipe( - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), - ), - )); - const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; - const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - const launch = yield* agentScope.wrap({ - command: input.binaryPath, - args, - name: "opencode", - env: input.environment, - }); - const spawnCommand = yield* resolveCommand(launch.command, launch.args, input.environment); - const serverPassword = resolveOpenCodeServerPassword({ - external: false, - ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), - ...(input.environment !== undefined ? { environment: input.environment } : {}), - }); - - // Scopes close in reverse order. Forking this before the group kill is - // registered forgets the ledger entry only once the group is stopped. - const ledgerScope = yield* Scope.fork(runtimeScope); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - detached: hostPlatform !== "win32", - shell: spawnCommand.shell, - env: { - ...input.environment, - ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), - // The value is set explicitly (rather than relying on inheritance) because - // `extendEnv` is false whenever `input.environment` is provided. - OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), - }, - extendEnv: input.environment === undefined, - }), - ) - .pipe( - Effect.provideService(Scope.Scope, runtimeScope), - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), - ), - ); - - const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => - hostPlatform === "win32" - ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) - : Effect.sync(() => { - try { - signalProcessGroup(Number(child.pid), signal); - } catch {} - }); - const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( - Effect.andThen(Effect.sleep("1 second")), - Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), - Effect.ignore, - ); - // Registered before recording, so an interrupt while the ledger writes - // still stops the group. - yield* Scope.addFinalizer(runtimeScope, terminateChild); - const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args }); - yield* Scope.addFinalizer(ledgerScope, forgetServer); - - const stdoutRef = yield* Ref.make(""); - const stderrRef = yield* Ref.make(""); - const readyDeferred = yield* Deferred.make(); - - const setReadyFromStdoutChunk = (chunk: string) => - Ref.modify(stdoutRef, (stdout) => { - if (stdout === null) { - return [null, null] as const; - } - const nextStdout = `${stdout}${chunk}`; - return [ - parseServerUrlFromOutput(nextStdout), - nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), - ] as const; - }).pipe( - Effect.flatMap((parsed) => - parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, - ), - ); + const startServerAttempt: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = Effect.fn( + "OpenCodeRuntime.startServerAttempt", + )(function* (input) { + const runtimeScope = yield* Scope.Scope; + + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = + input.port ?? + (yield* netService.findAvailablePort(0).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), + ), + )); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const launch = yield* agentScope.wrap({ + command: input.binaryPath, + args, + name: "opencode", + env: input.environment, + }); + const spawnCommand = yield* resolveCommand(launch.command, launch.args, input.environment); + const serverPassword = resolveOpenCodeServerPassword({ + external: false, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }); - const stdoutFiber = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.runForEach(setReadyFromStdoutChunk), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - const stderrFiber = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - Ref.update(stderrRef, (stderr) => - stderr === null - ? null - : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), - ), + // Scopes close in reverse order. Forking this before the group kill is + // registered forgets the ledger entry only once the group is stopped. + const ledgerScope = yield* Scope.fork(runtimeScope); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, + env: { + ...input.environment, + ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), + // The value is set explicitly (rather than relying on inheritance) because + // `extendEnv` is false whenever `input.environment` is provided. + OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), + }, + extendEnv: input.environment === undefined, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), ), - Effect.ignore, - Effect.forkIn(runtimeScope), ); - const exitFiber = yield* child.exitCode.pipe( - Effect.flatMap((code) => - Effect.gen(function* () { - // Allow trailing diagnostics to drain, but descendants can keep pipes open. - const drained = yield* Fiber.joinAll([stdoutFiber, stderrFiber]).pipe( - Effect.timeoutOption(OPENCODE_SERVER_EXIT_DRAIN_TIMEOUT), - ); - if (Option.isNone(drained)) { - yield* Fiber.interruptAll([stdoutFiber, stderrFiber]); - } - const stdout = (yield* Ref.get(stdoutRef)) ?? ""; - const stderr = (yield* Ref.get(stderrRef)) ?? ""; - const exitCode = Number(code); - const reason = classifyStartupExit(stderr); - yield* Deferred.fail( - readyDeferred, - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - ...(reason ? { reason } : {}), - detail: [ - `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, - stdout.trim() ? `stdout:\n${stdout.trim()}` : null, - stderr.trim() ? `stderr:\n${stderr.trim()}` : null, - ] - .filter(Boolean) - .join("\n\n"), - cause: { exitCode, stdout, stderr }, - }), - ).pipe(Effect.ignore); - }), + const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => + hostPlatform === "win32" + ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + signalProcessGroup(Number(child.pid), signal); + } catch {} + }); + const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), + Effect.ignore, + ); + // Registered before recording, so an interrupt while the ledger writes + // still stops the group. + yield* Scope.addFinalizer(runtimeScope, terminateChild); + const forgetServer = yield* serverLedger.track({ pid: Number(child.pid), port, args }); + yield* Scope.addFinalizer(ledgerScope, forgetServer); + + const stdoutRef = yield* Ref.make(""); + const stderrRef = yield* Ref.make(""); + const readyDeferred = yield* Deferred.make(); + + const setReadyFromStdoutChunk = (chunk: string) => + Ref.modify(stdoutRef, (stdout) => { + if (stdout === null) { + return [null, null] as const; + } + const nextStdout = `${stdout}${chunk}`; + return [ + parseServerUrlFromOutput(nextStdout), + nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ] as const; + }).pipe( + Effect.flatMap((parsed) => + parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, ), - Effect.ignore, - Effect.forkIn(runtimeScope), ); - const readyExit = yield* Effect.exit( - Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), - ); + const stdoutFiber = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach(setReadyFromStdoutChunk), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + const stderrFiber = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Ref.update(stderrRef, (stderr) => + stderr === null + ? null + : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ), + ), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); - if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { - yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); - } + const exitFiber = yield* child.exitCode.pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + // Allow trailing diagnostics to drain, but descendants can keep pipes open. + const drained = yield* Fiber.joinAll([stdoutFiber, stderrFiber]).pipe( + Effect.timeoutOption(OPENCODE_SERVER_EXIT_DRAIN_TIMEOUT), + ); + if (Option.isNone(drained)) { + yield* Fiber.interruptAll([stdoutFiber, stderrFiber]); + } + const stdout = (yield* Ref.get(stdoutRef)) ?? ""; + const stderr = (yield* Ref.get(stderrRef)) ?? ""; + const exitCode = Number(code); + const reason = classifyStartupExit(stderr); + yield* Deferred.fail( + readyDeferred, + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + ...(reason ? { reason } : {}), + detail: [ + `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + cause: { exitCode, stdout, stderr }, + }), + ).pipe(Effect.ignore); + }), + ), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); - if (Exit.isFailure(readyExit)) { - const squashed = Cause.squash(readyExit.cause); - return yield* ensureRuntimeError( - "startOpenCodeServerProcess", - `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, - squashed, - ); - } + const readyExit = yield* Effect.exit( + Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), + ); - const readyOption = readyExit.value; - if (Option.isNone(readyOption)) { - return yield* new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, - }); - } + if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { + yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); + } - // Keep draining both pipes until the process scope closes. Stopping the - // readers can block OpenCode when its output buffers fill. Startup output - // is no longer needed, so discard later output instead of retaining it. - yield* Ref.set(stdoutRef, null); - yield* Ref.set(stderrRef, null); - - const url = readyOption.value; - const version = yield* ( - input.verify?.(url) ?? - verifyOpenCodeServerVersion( - createOpenCodeSdkClient({ - baseUrl: url, - directory: input.directory, - ...(serverPassword !== undefined ? { serverPassword } : {}), - }), - ) + if (Exit.isFailure(readyExit)) { + const squashed = Cause.squash(readyExit.cause); + return yield* ensureRuntimeError( + "startOpenCodeServerProcess", + `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, + squashed, ); + } - return { - url, - ...(serverPassword !== undefined ? { serverPassword } : {}), - version, - isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), - exitCode: child.exitCode.pipe( - Effect.map(Number), - Effect.orElseSucceed(() => 0), - ), - } satisfies OpenCodeServerProcess; - }); + const readyOption = readyExit.value; + if (Option.isNone(readyOption)) { + return yield* new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + }); + } - const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = ( - input, - ) => - Effect.gen(function* () { + // Keep draining both pipes until the process scope closes. Stopping the + // readers can block OpenCode when its output buffers fill. Startup output + // is no longer needed, so discard later output instead of retaining it. + yield* Ref.set(stdoutRef, null); + yield* Ref.set(stderrRef, null); + + const url = readyOption.value; + const version = yield* ( + input.verify?.(url) ?? + verifyOpenCodeServerVersion( + createOpenCodeSdkClient({ + baseUrl: url, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), + ) + ); + + return { + url, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), + exitCode: child.exitCode.pipe( + Effect.map(Number), + Effect.orElseSucceed(() => 0), + ), + } satisfies OpenCodeServerProcess; + }); + + const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = + Effect.fn("OpenCodeRuntime.startOpenCodeServerProcess")(function* (input) { const ownerScope = yield* Scope.Scope; const attempt = Effect.gen(function* () { const attemptScope = yield* Scope.fork(ownerScope); @@ -1006,102 +1010,103 @@ const makeOpenCodeRuntime = Effect.gen(function* () { })), ); - const loadInventoryFromCli: OpenCodeRuntime["Service"]["loadInventoryFromCli"] = (input) => - Effect.gen(function* () { - const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); - const commandContext = { cwd: input.cwd, ...env }; - - const runModelsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["models", "--verbose"], - ...commandContext, - }).pipe(Effect.exit); - const runAgentsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["agent", "list"], - ...commandContext, - }).pipe(Effect.exit); - const runSkillsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["debug", "skill"], - maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, - ...commandContext, - }).pipe(Effect.exit); - - // Every OpenCode CLI command opens the same shared SQLite database. Running them - // concurrently causes "database is locked" failures, so run them one at a time. - const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( - [runModelsCli(), runAgentsCli(), runSkillsCli()], + const loadInventoryFromCli: OpenCodeRuntime["Service"]["loadInventoryFromCli"] = Effect.fn( + "OpenCodeRuntime.loadInventoryFromCli", + )(function* (input) { + const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); + const commandContext = { cwd: input.cwd, ...env }; + + const runModelsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["models", "--verbose"], + ...commandContext, + }).pipe(Effect.exit); + const runAgentsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["agent", "list"], + ...commandContext, + }).pipe(Effect.exit); + const runSkillsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, + ...commandContext, + }).pipe(Effect.exit); + + // Every OpenCode CLI command opens the same shared SQLite database. Running them + // concurrently causes "database is locked" failures, so run them one at a time. + const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( + [runModelsCli(), runAgentsCli(), runSkillsCli()], + { concurrency: 1 }, + ); + let modelsResult = initialModelsResult; + let agentsResult = initialAgentsResult; + let skillsResult = initialSkillsResult; + + const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; + const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; + const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; + if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { + yield* Effect.sleep("1 second"); + const [m2, a2, s2] = yield* Effect.all( + [ + needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), + needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), + needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), + ], { concurrency: 1 }, ); - let modelsResult = initialModelsResult; - let agentsResult = initialAgentsResult; - let skillsResult = initialSkillsResult; - - const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; - const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; - const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; - if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { - yield* Effect.sleep("1 second"); - const [m2, a2, s2] = yield* Effect.all( - [ - needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), - needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), - needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), - ], - { concurrency: 1 }, - ); - modelsResult = m2; - agentsResult = a2; - skillsResult = s2; - } - - if (modelsResult._tag === "Failure") { - const cause = Cause.squash(modelsResult.cause); - return yield* ensureRuntimeError( - "loadInventoryFromCli", - `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - ); - } - if (modelsResult.value.code !== 0) { - return yield* new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, - }); - } + modelsResult = m2; + agentsResult = a2; + skillsResult = s2; + } - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - const connected = [...parsed.connected]; - const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( - (provider) => ({ - id: provider.id, - name: provider.name, - source: "config" as const, - env: [], - options: {}, - models: provider.models, - }), + if (modelsResult._tag === "Failure") { + const cause = Cause.squash(modelsResult.cause); + return yield* ensureRuntimeError( + "loadInventoryFromCli", + `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, + cause, ); + } + if (modelsResult.value.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + }); + } - let agents: ReadonlyArray = []; - if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { - agents = parseAgentListCliOutput(agentsResult.value.stdout); - } - let skills: ReadonlyArray = []; - if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { - skills = parseSkillsCliOutput(skillsResult.value.stdout); - } + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const connected = [...parsed.connected]; + const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( + (provider) => ({ + id: provider.id, + name: provider.name, + source: "config" as const, + env: [], + options: {}, + models: provider.models, + }), + ); - return { - providerList: { all: allProviders, default: {}, connected }, - agents, - skills, - }; - }); + let agents: ReadonlyArray = []; + if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { + agents = parseAgentListCliOutput(agentsResult.value.stdout); + } + let skills: ReadonlyArray = []; + if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { + skills = parseSkillsCliOutput(skillsResult.value.stdout); + } + + return { + providerList: { all: allProviders, default: {}, connected }, + agents, + skills, + }; + }); const loadSkillsFromCli: OpenCodeRuntime["Service"]["loadSkillsFromCli"] = (input) => runOpenCodeCommand({ diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts index 9248f22cb877..e5c1866d5e22 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -99,26 +99,26 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { const directory = path.join(input.stateDir, ENTRY_DIRECTORY); const recordable = platform === "linux" || platform === "darwin"; - const observeLinux = (pid: number, group?: number) => - Effect.gen(function* () { - const stat = yield* fs.readFileString(`/proc/${pid}/stat`); - const fields = stat - .slice(stat.lastIndexOf(")") + 2) - .trim() - .split(/\s+/); - const pgid = Number(fields[2]); - const startTime = fields[19]; - if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined; - if (group !== undefined && pgid !== group) return undefined; - const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`); - return { - pid, - pgid, - startTime, - command: cmdline.replace(/\0$/, "").replaceAll("\0", " "), - zombie: fields[0] === "Z", - } satisfies ObservedProcess; - }); + // A process-group scan calls this for every /proc entry; avoid a span per process. + const observeLinux = Effect.fnUntraced(function* (pid: number, group?: number) { + const stat = yield* fs.readFileString(`/proc/${pid}/stat`); + const fields = stat + .slice(stat.lastIndexOf(")") + 2) + .trim() + .split(/\s+/); + const pgid = Number(fields[2]); + const startTime = fields[19]; + if (!Number.isSafeInteger(pgid) || startTime === undefined) return undefined; + if (group !== undefined && pgid !== group) return undefined; + const cmdline = yield* fs.readFileString(`/proc/${pid}/cmdline`); + return { + pid, + pgid, + startTime, + command: cmdline.replace(/\0$/, "").replaceAll("\0", " "), + zombie: fields[0] === "Z", + } satisfies ObservedProcess; + }); const psDarwin = (selection: ReadonlyArray) => spawner @@ -183,8 +183,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { const owner = ownerProcess === undefined ? undefined : { pid: ownerPid, startTime: ownerProcess.startTime }; - const track: OpenCodeServerLedger["Service"]["track"] = (server) => - Effect.gen(function* () { + const track: OpenCodeServerLedger["Service"]["track"] = Effect.fn("OpenCodeServerLedger.track")( + function* (server) { if (!recordable) return Effect.void; const pgid = server.pid; // The spawned process leads the group. If it already exited (a wrapper @@ -232,53 +232,57 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { Effect.logWarning("Could not forget a stopped OpenCode server", { cause }), ), ); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe( - Effect.as(Effect.void), - ), + }, + Effect.catchCause((cause) => + Effect.logWarning("Could not record an OpenCode server process", { cause }).pipe( + Effect.as(Effect.void), ), - ); + ), + ); // Signals only a group whose recorded member still has the same pid, start // time, group, and full command line, so a recycled pid is never touched. - const stopOrphan = (entry: OpenCodeServerEntry) => - Effect.gen(function* () { - const observed = yield* observe(entry.pid); - if ( - observed === undefined || - observed.zombie || - observed.startTime !== entry.startTime || - observed.pgid !== entry.pgid || - observed.command !== entry.command - ) { - return; - } - yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", { - pid: entry.pgid, - port: entry.port, - }); - signalGroup(entry.pgid, "SIGTERM"); - for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) { - yield* Effect.sleep(STOP_POLL_INTERVAL); - } - // The group never emptied, so its pgid cannot have been reused. - if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL"); + const stopOrphan = Effect.fn("OpenCodeServerLedger.stopOrphan")(function* ( + entry: OpenCodeServerEntry, + ) { + const observed = yield* observe(entry.pid); + if ( + observed === undefined || + observed.zombie || + observed.startTime !== entry.startTime || + observed.pgid !== entry.pgid || + observed.command !== entry.command + ) { + return; + } + yield* Effect.logInfo("Stopping an OpenCode server left by a previous T3 Code server", { + pid: entry.pgid, + port: entry.port, }); + signalGroup(entry.pgid, "SIGTERM"); + for (let attempt = 0; attempt < STOP_POLL_ATTEMPTS && groupExists(entry.pgid); attempt++) { + yield* Effect.sleep(STOP_POLL_INTERVAL); + } + // The group never emptied, so its pgid cannot have been reused. + if (groupExists(entry.pgid)) signalGroup(entry.pgid, "SIGKILL"); + }); - const reapEntry = (entryPath: string) => - Effect.gen(function* () { + const reapEntry = Effect.fn("OpenCodeServerLedger.reapEntry")( + function* (entryPath: string) { const entry = decodeEntry(yield* fs.readFileString(entryPath)); if (Option.isSome(entry) && entry.value.stateDir === input.stateDir) { if (yield* isRunning(entry.value.owner)) return; yield* stopOrphan(entry.value); } yield* fs.remove(entryPath, { force: true }); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }), + }, + (effect, entryPath) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not clean up a recorded OpenCode server", { entryPath, cause }), + ), ), - ); + ); const reapOrphans = Effect.gen(function* () { const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 4a73195d2dc7..2d27f83a8add 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -1047,56 +1047,55 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ), ); - const abortDescendants = (rootId: string) => - Effect.gen(function* () { - const visited = new Set([rootId]); - const semaphore = Semaphore.makeUnsafe(8); - const visit = ( - sessionId: string, - abort: boolean, - ): Effect.Effect => - Effect.gen(function* () { - const abortResult = abort - ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => - client.session.abort({ sessionID: sessionId }, { signal }), - ).pipe( - semaphore.withPermit, - Effect.catchIf(isOpenCodeNotFound, () => Effect.void), - Effect.result, - ) - : undefined; - const childrenResult = yield* sdkCall( - "session.children", - { sessionID: sessionId }, - (signal) => client.session.children({ sessionID: sessionId }, { signal }), + const abortDescendants = Effect.fn("OpencodeAdapter.abortDescendants")(function* ( + rootId: string, + ) { + const visited = new Set([rootId]); + const semaphore = Semaphore.makeUnsafe(8); + const visit = Effect.fn("OpencodeAdapter.visit")(function* ( + sessionId: string, + abort: boolean, + ): Effect.fn.Return { + const abortResult = abort + ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) => + client.session.abort({ sessionID: sessionId }, { signal }), ).pipe( semaphore.withPermit, Effect.catchIf(isOpenCodeNotFound, () => Effect.void), Effect.result, - ); - const firstFailure = - abortResult?._tag === "Failure" ? abortResult.failure : undefined; - if (childrenResult._tag === "Failure") - return firstFailure ?? childrenResult.failure; - const fresh = (childrenResult.success?.data ?? []).filter((child) => { - if (visited.has(child.id)) return false; - visited.add(child.id); - return true; - }); - const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), { - concurrency: 8, - }); - return firstFailure ?? failures.find((failure) => failure !== undefined); - }); - const failure = yield* visit(rootId, false); - if (failure) return yield* Effect.fail(failure); - }).pipe(Effect.timeout("15 seconds")); - - const updateProviderSession = ( - status: OrchestrationV2ProviderSession["status"], - lastError: string | null = sessionEntity.lastError, - ) => - Effect.gen(function* () { + ) + : undefined; + const childrenResult = yield* sdkCall( + "session.children", + { sessionID: sessionId }, + (signal) => client.session.children({ sessionID: sessionId }, { signal }), + ).pipe( + semaphore.withPermit, + Effect.catchIf(isOpenCodeNotFound, () => Effect.void), + Effect.result, + ); + const firstFailure = abortResult?._tag === "Failure" ? abortResult.failure : undefined; + if (childrenResult._tag === "Failure") return firstFailure ?? childrenResult.failure; + const fresh = (childrenResult.success?.data ?? []).filter((child) => { + if (visited.has(child.id)) return false; + visited.add(child.id); + return true; + }); + const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), { + concurrency: 8, + }); + return firstFailure ?? failures.find((failure) => failure !== undefined); + }); + const failure = yield* visit(rootId, false); + if (failure) return yield* Effect.fail(failure); + }, Effect.timeout("15 seconds")); + + const updateProviderSession = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) { const updatedAt = yield* DateTime.now; sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; yield* emitProviderEvent({ @@ -1106,11 +1105,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }); }); - const updateProviderThread = ( - state: OpenCodeThreadState, - patch: Partial, - ) => - Effect.gen(function* () { + const updateProviderThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + state: OpenCodeThreadState, + patch: Partial, + ) { const updatedAt = yield* DateTime.now; state.providerThread = { ...state.providerThread, ...patch, updatedAt }; yield* emitProviderEvent({ @@ -2998,8 +2998,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function providerSession: sessionEntity, events: Stream.fromEffectRepeat(Queue.take(events)), hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), - ensureThread: (threadInput) => - Effect.gen(function* () { + ensureThread: Effect.fn("OpencodeAdapter.ensureThread")( + function* (threadInput) { if (threadInput.existingProviderThread?.nativeThreadRef != null) { return yield* runtimeSession.resumeThread({ providerThread: threadInput.existingProviderThread, @@ -3039,18 +3039,21 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }; registerThread(nativeSession, providerThread, null); return providerThread; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterEnsureThreadError({ - driver: OPENCODE_PROVIDER, - threadId: threadInput.threadId, - cause, - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterEnsureThreadError({ + driver: OPENCODE_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), ), - ), - resumeThread: (threadInput) => - Effect.gen(function* () { + ), + resumeThread: Effect.fn("OpencodeAdapter.resumeThread")( + function* (threadInput) { const sessionId = nativeThreadId(threadInput.providerThread); const response = yield* sdkCall("session.get", { sessionID: sessionId }, () => client.session.get({ sessionID: sessionId }), @@ -3065,24 +3068,27 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }; registerThread(nativeSession, providerThread, null); return providerThread; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver: OPENCODE_PROVIDER, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver: OPENCODE_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), ), - ), + ), compactThread: (turnInput) => runtimeSession.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" }, }), - startTurn: (turnInput) => - Effect.gen(function* () { + startTurn: Effect.fn("OpencodeAdapter.startTurn")( + function* (turnInput) { if (nativeStreamFailure !== null) { return yield* protocolError( "OpenCode event stream has ended; reconnect the provider session before starting another turn.", @@ -3279,20 +3285,23 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function yield* reconcilePromptAdmission(state, turn); } } - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: OPENCODE_PROVIDER, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: OPENCODE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), ), - ), - steerTurn: (steerInput) => - Effect.gen(function* () { + ), + steerTurn: Effect.fn("OpencodeAdapter.steerTurn")( + function* (steerInput) { const sessionId = nativeThreadId(steerInput.providerThread); const state = threads.get(sessionId); const turn = state?.activeTurn; @@ -3369,19 +3378,22 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function yield* reconcilePromptAdmission(state, turn); } } - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterSteerRunError({ - driver: OPENCODE_PROVIDER, - providerThreadId: steerInput.providerThread.id, - providerTurnId: steerInput.providerTurnId, - cause, - }), + }, + (effect, steerInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver: OPENCODE_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), ), - ), - interruptTurn: (interruptInput) => - Effect.gen(function* () { + ), + interruptTurn: Effect.fn("OpencodeAdapter.interruptTurn")( + function* (interruptInput) { const sessionId = nativeThreadId(interruptInput.providerThread); const state = threads.get(sessionId); const turn = state?.activeTurn; @@ -3432,19 +3444,22 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function // Root abort does not stop child sessions. Report incomplete cleanup // even if the root has already emitted its terminal event. yield* abortDescendants(sessionId); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: OPENCODE_PROVIDER, - providerThreadId: interruptInput.providerThread.id, - providerTurnId: interruptInput.providerTurnId, - cause, - }), + }, + (effect, interruptInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: OPENCODE_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), ), - ), - respondToRuntimeRequest: (requestInput) => - Effect.gen(function* () { + ), + respondToRuntimeRequest: Effect.fn("OpencodeAdapter.respondToRuntimeRequest")( + function* (requestInput) { const pending = pendingRequests.get(String(requestInput.requestId)); if (pending === undefined) { return yield* protocolError( @@ -3493,16 +3508,19 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function { signal }, ), ).pipe(Effect.timeout("10 seconds")); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver: OPENCODE_PROVIDER, - requestId: requestInput.requestId, - cause, - }), + }, + (effect, requestInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: OPENCODE_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), ), - ), + ), readThreadSnapshot: (snapshotInput) => readSnapshot(snapshotInput.providerThread).pipe( Effect.mapError( @@ -3514,8 +3532,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }), ), ), - rollbackThread: (rollbackInput) => - Effect.gen(function* () { + rollbackThread: Effect.fn("OpencodeAdapter.rollbackThread")( + function* (rollbackInput) { const sessionId = nativeThreadId(rollbackInput.providerThread); const state = threads.get(sessionId); if (state?.activeTurn !== null && state?.activeTurn !== undefined) { @@ -3587,19 +3605,22 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function : null, }, }; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: OPENCODE_PROVIDER, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause, - }), + }, + (effect, rollbackInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: OPENCODE_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), ), - ), - forkThread: (forkInput) => - Effect.gen(function* () { + ), + forkThread: Effect.fn("OpencodeAdapter.forkThread")( + function* (forkInput) { const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread); const sourceState = threads.get(sourceSessionId); if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) { @@ -3651,16 +3672,19 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }); registerThread(nativeSession, providerThread, null); return providerThread; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterForkThreadError({ - driver: OPENCODE_PROVIDER, - providerThreadId: forkInput.sourceProviderThread.id, - cause, - }), + }, + (effect, forkInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: OPENCODE_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), ), - ), + ), }; return runtimeSession; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 9c522d554ce0..8c590ca100bc 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -186,343 +186,341 @@ export const OpenCodeDriver: ProviderDriver< configSchema: OpenCodeSettings, defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), usage: openCodeUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const crypto = yield* Crypto.Crypto; - const host = yield* ProviderHost.ProviderHost; - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; - const runtimeProbe = yield* makeOpenCodeRuntimeProbe( - probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), - ), - ); - const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, - }); - const maintenanceFor = (probed: typeof runtimeProbe.get) => - probed.pipe( - Effect.map((result) => result.generation), - Effect.option, - Effect.flatMap((generation) => - Option.isNone(generation) - ? Effect.succeed(noUpdate) - : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }), - ), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ); - const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( - maintenanceFor(runtimeProbe.get), - ); - const resolveMaintenance = (options?: { readonly fresh?: boolean }) => - !effectiveConfig.enabled - ? Effect.succeed(noUpdate) - : options?.fresh === true - ? runtimeProbe.refresh.pipe( - Effect.ignore, - Effect.andThen(cachedMaintenance({ fresh: true })), - ) - : cachedMaintenance(); - const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build OpenCode orchestration adapter.", - cause, - }), - ), - ); - const openCode2Server = yield* OpenCode2Server.make({ - binaryPath: effectiveConfig.binaryPath, - serverUrl: effectiveConfig.serverUrl, - serverPassword: effectiveConfig.serverPassword, - directory: host.paths.cwd, - environment: processEnv, - }).pipe( + create: Effect.fn("OpencodeDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const crypto = yield* Crypto.Crypto; + const host = yield* ProviderHost.ProviderHost; + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; + const runtimeProbe = yield* makeOpenCodeRuntimeProbe( + probeOpenCodeRuntime(effectiveConfig, processEnv).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), - Effect.provideService( - OpenCode2Client.OpenCode2Client, - yield* OpenCode2Client.make.pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - ), - ), - ); - const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ - probe: runtimeProbe, - v1: openCodeV1Adapter, - v2: yield* OpenCode2AdapterV2.make(instanceId).pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), - ), - }); - const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( - openCode2Server.withConnection(({ client }) => - loadOpenCode2Catalog(client, host.paths.cwd), + ), + ); + const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }); + const maintenanceFor = (probed: typeof runtimeProbe.get) => + probed.pipe( + Effect.map((result) => result.generation), + Effect.option, + Effect.flatMap((generation) => + Option.isNone(generation) + ? Effect.succeed(noUpdate) + : resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation.value), { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }), ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), ); - const listOpenCode2Workspace = (cwd: string) => - openCode2Server.withConnection(({ client, events }) => - Effect.gen(function* () { - const location = { directory: cwd }; - const scanned = yield* Deferred.make(); - const pending = new Set(["command.updated", "skill.updated"]); - const stream = yield* events.pipe(Effect.option); - if (stream._tag === "Some") { - yield* stream.value.pipe( - Stream.runForEach((event) => - "location" in event && - event.location?.directory === cwd && - pending.delete(event.type) && - pending.size === 0 - ? Deferred.succeed(scanned, undefined) - : Effect.void, - ), - Effect.ignore, - Effect.forkScoped, - ); - } - return yield* loadOpenCode2Workspace( - Effect.all( - { - skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), - commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), - }, - { concurrency: "unbounded" }, + const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( + maintenanceFor(runtimeProbe.get), + ); + const resolveMaintenance = (options?: { readonly fresh?: boolean }) => + !effectiveConfig.enabled + ? Effect.succeed(noUpdate) + : options?.fresh === true + ? runtimeProbe.refresh.pipe( + Effect.ignore, + Effect.andThen(cachedMaintenance({ fresh: true })), + ) + : cachedMaintenance(); + const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenCode orchestration adapter.", + cause, + }), + ), + ); + const openCode2Server = yield* OpenCode2Server.make({ + binaryPath: effectiveConfig.binaryPath, + serverUrl: effectiveConfig.serverUrl, + serverPassword: effectiveConfig.serverPassword, + directory: host.paths.cwd, + environment: processEnv, + }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + Effect.provideService( + OpenCode2Client.OpenCode2Client, + yield* OpenCode2Client.make.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + ), + ); + const orchestrationAdapter = selectOpenCodeRuntimeAdapter({ + probe: runtimeProbe, + v1: openCodeV1Adapter, + v2: yield* OpenCode2AdapterV2.make(instanceId).pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ), + }); + const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( + openCode2Server.withConnection(({ client }) => loadOpenCode2Catalog(client, host.paths.cwd)), + ); + const listOpenCode2Workspace = (cwd: string) => + openCode2Server.withConnection(({ client, events }) => + Effect.gen(function* () { + const location = { directory: cwd }; + const scanned = yield* Deferred.make(); + const pending = new Set(["command.updated", "skill.updated"]); + const stream = yield* events.pipe(Effect.option); + if (stream._tag === "Some") { + yield* stream.value.pipe( + Stream.runForEach((event) => + "location" in event && + event.location?.directory === cwd && + pending.delete(event.type) && + pending.size === 0 + ? Deferred.succeed(scanned, undefined) + : Effect.void, ), - Deferred.await(scanned), + Effect.ignore, + Effect.forkScoped, ); - }).pipe(Effect.scoped), - ); - const serverOwner = yield* OpenCodeServerOwner.make({ - binaryPath: effectiveConfig.binaryPath, - directory: host.paths.cwd, - ...(effectiveConfig.serverPassword - ? { serverPassword: effectiveConfig.serverPassword } - : {}), - environment: processEnv, - }); - const textGeneration = selectOpenCodeRuntimeTextGeneration( - runtimeProbe, - yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - ), - yield* OpenCode2TextGeneration.make().pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), - ), + } + return yield* loadOpenCode2Workspace( + Effect.all( + { + skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), + commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), + }, + { concurrency: "unbounded" }, + ), + Deferred.await(scanned), + ); + }).pipe(Effect.scoped), ); + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: effectiveConfig.binaryPath, + directory: host.paths.cwd, + ...(effectiveConfig.serverPassword ? { serverPassword: effectiveConfig.serverPassword } : {}), + environment: processEnv, + }); + const textGeneration = selectOpenCodeRuntimeTextGeneration( + runtimeProbe, + yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ), + yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ), + ); - const checkProvider = Effect.all( + const checkProvider = Effect.all( + { + provider: checkOpenCodeProviderStatus( + effectiveConfig, + host.paths.cwd, + runtimeProbe.refresh, + loadOpenCode2Models, + ), + usageLimits: readOpenCodeGoUsageLimits({ + enabled: effectiveConfig.enabled, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + }), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), + Effect.map(stampIdentity), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + ); + // The CLI writes its full JSON inventory to stdout, but the Bun-compiled binary does not + // flush more than one 64KB pipe buffer to a non-TTY stdout, so the piped output arrives + // truncated and unparseable — which degrades to an empty skill list and poisons the workspace + // snapshot the `$` picker reads. + const loadWorkspaceInventory = ( + client: Parameters[0], + ) => + Effect.all( { - provider: checkOpenCodeProviderStatus( - effectiveConfig, - host.paths.cwd, - runtimeProbe.refresh, - loadOpenCode2Models, + skills: openCodeRuntime.loadOpenCodeSkills(client), + commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( + Effect.timeout("10 seconds"), + Effect.option, ), - usageLimits: readOpenCodeGoUsageLimits({ - enabled: effectiveConfig.enabled, - serverUrl: effectiveConfig.serverUrl, - environment: processEnv, - }), }, { concurrency: "unbounded" }, ).pipe( - Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), - Effect.map(stampIdentity), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + // Only a finished lookup is reported as complete. A failed one leaves + // the flag unset, so the registry decides from the provider's health. + Effect.map(({ skills, commands }) => ({ + skills, + commands: Option.getOrElse(commands, () => []), + slashCommandsPending: Option.isSome(commands) ? false : undefined, + })), ); - // The CLI writes its full JSON inventory to stdout, but the Bun-compiled binary does not - // flush more than one 64KB pipe buffer to a non-TTY stdout, so the piped output arrives - // truncated and unparseable — which degrades to an empty skill list and poisons the workspace - // snapshot the `$` picker reads. - const loadWorkspaceInventory = ( - client: Parameters[0], - ) => - Effect.all( - { - skills: openCodeRuntime.loadOpenCodeSkills(client), - commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( - Effect.timeout("10 seconds"), - Effect.option, - ), - }, - { concurrency: "unbounded" }, - ).pipe( - // Only a finished lookup is reported as complete. A failed one leaves - // the flag unset, so the registry decides from the provider's health. - Effect.map(({ skills, commands }) => ({ - skills, - commands: Option.getOrElse(commands, () => []), - slashCommandsPending: Option.isSome(commands) ? false : undefined, - })), - ); - const loadWorkspaceForCwd = (cwd: string) => - effectiveConfig.serverUrl.trim().length > 0 - ? Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath: effectiveConfig.binaryPath, - directory: cwd, - serverUrl: effectiveConfig.serverUrl, - ...(effectiveConfig.serverPassword - ? { serverPassword: effectiveConfig.serverPassword } - : {}), - environment: processEnv, - }); - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(effectiveConfig.serverPassword - ? { serverPassword: effectiveConfig.serverPassword } - : {}), - }); - return yield* loadWorkspaceInventory(client); + const loadWorkspaceForCwd = (cwd: string) => + effectiveConfig.serverUrl.trim().length > 0 + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: effectiveConfig.binaryPath, + directory: cwd, + serverUrl: effectiveConfig.serverUrl, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + environment: processEnv, + }); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + }); + return yield* loadWorkspaceInventory(client); + }), + ) + : serverOwner.withServer((server) => + loadWorkspaceInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined + ? { serverPassword: server.serverPassword } + : {}), }), - ) - : serverOwner.withServer((server) => - loadWorkspaceInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined - ? { serverPassword: server.serverPassword } - : {}), - }), - ), - ); - - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>( - { - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - checkProviderOnSettingsChange: () => false, - refreshOnInterval: false, - initialSnapshot: (settings) => - makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, - cause, + ); + + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + checkProviderOnSettingsChange: () => false, + refreshOnInterval: false, + initialSnapshot: (settings) => + makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - snapshotForCwd: (cwd) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : byOpenCodeRuntime(runtimeProbe.get, { - v2: Effect.all([ - snapshot.getSnapshot, - listOpenCode2Workspace(cwd).pipe( - Effect.timeout("20 seconds"), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to list OpenCode commands and skills for '${cwd}'`, - cause, - }), - ), - ), - ]).pipe( - Effect.map(([machineSnapshot, { skills, commands }]) => ({ - ...machineSnapshot, - skills: openCode2SkillsToServerProviderSkills(skills), - slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), - // A finished scan lists at least the built-ins. None means it - // timed out, so the registry decides from the provider's health. - slashCommandsPending: commands.length > 0 ? false : undefined, - })), - ), - v1: Effect.all([ - snapshot.getSnapshot, - loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), - ]).pipe( - Effect.map(([machineSnapshot, { skills, commands, slashCommandsPending }]) => ({ - ...machineSnapshot, - skills: openCodeSkillsToServerProviderSkills(skills), - slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), - slashCommandsPending, - })), + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : byOpenCodeRuntime(runtimeProbe.get, { + v2: Effect.all([ + snapshot.getSnapshot, + listOpenCode2Workspace(cwd).pipe( + Effect.timeout("20 seconds"), Effect.mapError( (cause) => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, + detail: `Failed to list OpenCode commands and skills for '${cwd}'`, cause, }), ), ), - }), - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), + ]).pipe( + Effect.map(([machineSnapshot, { skills, commands }]) => ({ + ...machineSnapshot, + skills: openCode2SkillsToServerProviderSkills(skills), + slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), + // A finished scan lists at least the built-ins. None means it + // timed out, so the registry decides from the provider's health. + slashCommandsPending: commands.length > 0 ? false : undefined, + })), + ), + v1: Effect.all([ + snapshot.getSnapshot, + loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), + ]).pipe( + Effect.map(([machineSnapshot, { skills, commands, slashCommandsPending }]) => ({ + ...machineSnapshot, + skills: openCodeSkillsToServerProviderSkills(skills), + slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), + slashCommandsPending, + })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, + cause, + }), + ), + ), + }), + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), }; diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 4557ce3f9728..1fe35dfadb1b 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -328,39 +328,20 @@ export function openCodeCommandsToServerProviderSlashCommands( return commands; } -export const makePendingOpenCodeProvider = ( - openCodeSettings: OpenCodeSettings, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = providerModelsFromSettings( - [], - openCodeSettings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: - openCodeSettings.serverUrl.trim().length > 0 - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } +export const makePendingOpenCodeProvider = Effect.fn( + "OpencodeProviderStatus.makePendingOpenCodeProvider", +)(function* (openCodeSettings: OpenCodeSettings): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + [], + openCodeSettings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + if (!openCodeSettings.enabled) { return buildServerProvider({ presentation: OPENCODE_PRESENTATION, - enabled: true, + enabled: false, checkedAt, models, probe: { @@ -368,10 +349,28 @@ export const makePendingOpenCodeProvider = ( version: null, status: "warning", auth: { status: "unknown" }, - message: "OpenCode provider status has not been checked in this session yet.", + message: + openCodeSettings.serverUrl.trim().length > 0 + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", }, }); + } + + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode provider status has not been checked in this session yet.", + }, }); +}); export interface OpenCode2Model { readonly providerID: string; @@ -461,16 +460,14 @@ export interface OpenCode2Workspace { * for the directory end the scan. `scanned` must be listening before `read` runs, since * the read is what starts the scan. */ -export const loadOpenCode2Workspace = ( - read: Effect.Effect, - scanned: Effect.Effect, -) => - Effect.gen(function* () { +export const loadOpenCode2Workspace = Effect.fn("OpencodeProviderStatus.loadOpenCode2Workspace")( + function* (read: Effect.Effect, scanned: Effect.Effect) { const first = yield* read; if (first.commands.length > 0) return first; yield* scanned.pipe(Effect.timeoutOption("10 seconds")); return yield* read; - }); + }, +); export function openCode2SkillsToServerProviderSkills( skills: OpenCode2Workspace["skills"], diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index 88a56ad00cd1..41d11a70cbc8 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -91,34 +91,34 @@ const decodeEnvelope = Schema.decodeUnknownOption( ), ); -const undecodable = (data: string) => - Effect.gen(function* () { - const envelope = decodeEnvelope(data); - const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : ""; - const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; - if (isExecutionEnd(type) && sessionID !== undefined) { - yield* Effect.logWarning( - "Ended an OpenCode execution from an event this build cannot decode.", - { - type, - }, - ); - return Result.succeed({ - type: "unreadable.execution.ended", - executionType: type, - sessionID, - }); - } - if (type === "session.execution.started" && sessionID !== undefined) { - yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode."); - return Result.succeed({ - type: "unreadable.execution.started", - sessionID, - }); - } - yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); - return Result.failVoid; - }); +// Rejected stream frames can be frequent when the server introduces new event types. +const undecodable = Effect.fnUntraced(function* (data: string) { + const envelope = decodeEnvelope(data); + const type = envelope._tag === "Some" ? envelope.value.type.slice(0, 80) : ""; + const sessionID = envelope._tag === "Some" ? envelope.value.data?.sessionID : undefined; + if (isExecutionEnd(type) && sessionID !== undefined) { + yield* Effect.logWarning( + "Ended an OpenCode execution from an event this build cannot decode.", + { + type, + }, + ); + return Result.succeed({ + type: "unreadable.execution.ended", + executionType: type, + sessionID, + }); + } + if (type === "session.execution.started" && sessionID !== undefined) { + yield* Effect.logDebug("Read an OpenCode execution start this build cannot decode."); + return Result.succeed({ + type: "unreadable.execution.started", + sessionID, + }); + } + yield* Effect.logDebug("Skipped an OpenCode event this build cannot decode.", { type }); + return Result.failVoid; +}); const readEvents = HttpClient.get("/api/event", { headers: { accept: "text/event-stream" }, diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts index 5991d5758629..c8a71531fe36 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -136,12 +136,15 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { readonly environment: NodeJS.ProcessEnv; }) { const opencode = yield* OpenCode2Client.OpenCode2Client; - const connectTo = (url: string, password: Redacted.Redacted, external: boolean) => - Effect.gen(function* () { - const api = yield* opencode.connect({ baseUrl: url, password }); - const version = yield* verifyServer(api.client); - return { ...api, url, version, external } satisfies OpenCode2Connection; - }); + const connectTo = Effect.fn("OpenCode2Server.connectTo")(function* ( + url: string, + password: Redacted.Redacted, + external: boolean, + ) { + const api = yield* opencode.connect({ baseUrl: url, password }); + const version = yield* verifyServer(api.client); + return { ...api, url, version, external } satisfies OpenCode2Connection; + }); let latest: OpenCode2Connection | undefined; const remember = (connection: OpenCode2Connection) => Effect.sync(() => { diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index b664eabd8ab6..9887cb554ed1 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -839,11 +839,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork); - const setSessionStatus = ( - status: OrchestrationV2ProviderSession["status"], - lastError: string | null, - ) => - Effect.gen(function* () { + const setSessionStatus = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null, + ) { // The session is shared by every thread on this instance and is persisted // once per attached thread, so a repeat of the same state is not worth a write. if (session.status === status && session.lastError === lastError) return; @@ -3051,55 +3052,54 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (turn !== undefined) turn.before = before; }); - const beginTurn = ( + const beginTurn = Effect.fn("OpenCode2Adapter.beginTurn")(function* ( state: ThreadState, turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, awaitingStart: boolean, - ) => - Effect.gen(function* () { - const startedAt = yield* DateTime.now; - const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`; - const providerTurn: OrchestrationV2ProviderTurn = { - id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: ref( - isContinuation(turnInput) - ? wakeTurnId(state.sessionId, turnInput.attemptId) - : turnPromptId(state.sessionId, turnInput.attemptId), - "weak", - ), - ordinal: turnInput.providerTurnOrdinal, - status: "running", - startedAt, - completedAt: null, - }; - const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); - if (streamFailure !== undefined) { - return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ - driver, - providerSessionId: input.providerSessionId, - cause: streamFailure, - }); - } - state.active = turn; - yield* emitProviderTurn(state, turn, providerTurn); - state.providerThread = { - ...state.providerThread, - status: "active", - firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, - lastRunOrdinal: turnInput.runOrdinal, - updatedAt: startedAt, - }; - yield* emit({ - type: "provider_thread.updated", + ) { + const startedAt = yield* DateTime.now; + const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ driver, nativeTurnId }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: ref( + isContinuation(turnInput) + ? wakeTurnId(state.sessionId, turnInput.attemptId) + : turnPromptId(state.sessionId, turnInput.attemptId), + "weak", + ), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); + if (streamFailure !== undefined) { + return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ driver, - providerThread: state.providerThread, + providerSessionId: input.providerSessionId, + cause: streamFailure, }); - yield* setSessionStatus("running", null); - return turn; + } + state.active = turn; + yield* emitProviderTurn(state, turn, providerTurn); + state.providerThread = { + ...state.providerThread, + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + updatedAt: startedAt, + }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, }); + yield* setSessionStatus("running", null); + return turn; + }); const replay = Effect.fnUntraced(function* (wake: Wake) { wake.dropped = true; @@ -3344,8 +3344,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr selection.instanceId === instanceId ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model) : undefined, - ensureThread: (threadInput) => - Effect.gen(function* () { + ensureThread: Effect.fn("OpenCode2Adapter.ensureThread")( + function* (threadInput) { if (threadInput.existingProviderThread?.nativeThreadRef != null) { return yield* runtime.resumeThread({ providerThread: threadInput.existingProviderThread, @@ -3410,19 +3410,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); state.policy = policy; return state.providerThread; - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterEnsureThreadError({ - driver, - threadId: threadInput.threadId, - cause, - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterEnsureThreadError({ + driver, + threadId: threadInput.threadId, + cause, + }), + ), ), - ), - resumeThread: (threadInput) => - Effect.gen(function* () { + ), + resumeThread: Effect.fn("OpenCode2Adapter.resumeThread")( + function* (threadInput) { const sessionId = yield* sessionIdOf(threadInput.providerThread); if (threadInput.runtimePolicy !== undefined) { yield* readModelsOnce(threadInput.runtimePolicy.cwd); @@ -3446,22 +3449,25 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); } return state.providerThread; - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), ), - ), + ), compactThread: (turnInput) => runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }), - startTurn: (turnInput) => - Effect.gen(function* () { + startTurn: Effect.fn("OpenCode2Adapter.startTurn")( + function* (turnInput) { // Waited out before the session's gate, so a rollback or fork is not // held behind a reconnect. if (streamFailure !== undefined) { @@ -3604,21 +3610,24 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr : Effect.void, ), ); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterTurnStartError({ - driver, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterTurnStartError({ + driver, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), ), - ), - steerTurn: (steerInput) => - Effect.gen(function* () { + ), + steerTurn: Effect.fn("OpenCode2Adapter.steerTurn")( + function* (steerInput) { const sessionId = yield* sessionIdOf(steerInput.providerThread); const state = threads.get(sessionId); const turn = state?.active; @@ -3680,20 +3689,23 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ), ); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterSteerRunError({ - driver, - providerThreadId: steerInput.providerThread.id, - providerTurnId: steerInput.providerTurnId, - cause, - }), + }, + (effect, steerInput) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterSteerRunError({ + driver, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), ), - ), - interruptTurn: (interruptInput) => - Effect.gen(function* () { + ), + interruptTurn: Effect.fn("OpenCode2Adapter.interruptTurn")( + function* (interruptInput) { const sessionId = yield* sessionIdOf(interruptInput.providerThread); const state = threads.get(sessionId); if (state === undefined) return; @@ -3738,40 +3750,42 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: `OpenCode session ${sessionId} had nothing running to stop`, }); } - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterInterruptError({ - driver, - providerThreadId: interruptInput.providerThread.id, - providerTurnId: interruptInput.providerTurnId, - cause, - }), + }, + (effect, interruptInput) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterInterruptError({ + driver, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), ), - ), - unloadThread: ({ providerThread }) => - Effect.gen(function* () { - const nativeId = providerThread.nativeThreadRef?.nativeId; - const state = nativeId == null ? undefined : threads.get(nativeId); - if ( - nativeId == null || - state === undefined || - state.active !== undefined || - hasBackground(state) - ) { - return; - } - threads.delete(nativeId); - for (const [child, owner] of childOwners) { - if (owner !== state) continue; - childOwners.delete(child); - threads.delete(child); - } - if (state.mcp !== undefined) yield* removeMcp(state.mcp); - }), - respondToRuntimeRequest: (requestInput) => - Effect.gen(function* () { + ), + unloadThread: Effect.fn("OpenCode2Adapter.unloadThread")(function* ({ providerThread }) { + const nativeId = providerThread.nativeThreadRef?.nativeId; + const state = nativeId == null ? undefined : threads.get(nativeId); + if ( + nativeId == null || + state === undefined || + state.active !== undefined || + hasBackground(state) + ) { + return; + } + threads.delete(nativeId); + for (const [child, owner] of childOwners) { + if (owner !== state) continue; + childOwners.delete(child); + threads.delete(child); + } + if (state.mcp !== undefined) yield* removeMcp(state.mcp); + }), + respondToRuntimeRequest: Effect.fn("OpenCode2Adapter.respondToRuntimeRequest")( + function* (requestInput) { const entry = pending.get(requestInput.requestId); if (entry === undefined || entry.answering) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ @@ -3844,34 +3858,40 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); yield* forgetRequest(entry); if (!delivered) yield* abandonRequest(asker, "answer not delivered"); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver, - requestId: requestInput.requestId, - cause, - }), + }, + (effect, requestInput) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver, + requestId: requestInput.requestId, + cause, + }), + ), ), - ), - readThreadSnapshot: ({ providerThread }) => - Effect.gen(function* () { + ), + readThreadSnapshot: Effect.fn("OpenCode2Adapter.readThreadSnapshot")( + function* ({ providerThread }) { const sessionId = yield* sessionIdOf(providerThread); return yield* snapshotOf(providerThread, sessionId); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver, - providerThreadId: providerThread.id, - cause, - }), + }, + (effect, { providerThread }) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver, + providerThreadId: providerThread.id, + cause, + }), + ), ), - ), - rollbackThread: (rollbackInput) => - Effect.gen(function* () { + ), + rollbackThread: Effect.fn("OpenCode2Adapter.rollbackThread")( + function* (rollbackInput) { const { providerThread, target } = rollbackInput; const sessionId = yield* sessionIdOf(providerThread); yield* ensureQuiet(sessionId, "roll back"); @@ -3904,21 +3924,24 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); for (const turn of snapshot.providerTurns) kept.set(turn.id, turn); return { ...snapshot, providerTurns: [...kept.values()] }; - }).pipe( - exclusive(rollbackInput.providerThread), - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause, - }), + }, + (effect, rollbackInput) => + effect.pipe( + exclusive(rollbackInput.providerThread), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), ), - ), - forkThread: (forkInput) => - Effect.gen(function* () { + ), + forkThread: Effect.fn("OpenCode2Adapter.forkThread")( + function* (forkInput) { const source = forkInput.sourceProviderThread; const sourceId = yield* sessionIdOf(source); // OpenCode forks whatever history the source has, so a fork taken @@ -3983,18 +4006,21 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); } return state.providerThread; - }).pipe( - exclusive(forkInput.sourceProviderThread), - Effect.mapError((cause) => - isProviderAdapterError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterForkThreadError({ - driver, - providerThreadId: forkInput.sourceProviderThread.id, - cause, - }), + }, + (effect, forkInput) => + effect.pipe( + exclusive(forkInput.sourceProviderThread), + Effect.mapError((cause) => + isProviderAdapterError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterForkThreadError({ + driver, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), ), - ), + ), }; return runtime; }); @@ -4006,21 +4032,24 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), // The session borrows the instance's server for as long as it is open, so a // spawned server is not idle-stopped under a long tool call. - openSession: (input) => - Effect.gen(function* () { + openSession: Effect.fn("OpenCode2Adapter.borrowAndOpenSession")( + function* (input) { const scope = yield* Scope.make(); yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)); const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); return yield* openSession(input, { connection, scope }); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver, - providerSessionId: input.providerSessionId, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver, + providerSessionId: input.providerSessionId, + cause, + }), + ), ), - ), + ), }); }); diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts index 252431bc3594..108271afc674 100644 --- a/packages/provider-opencode/src/server/v2/textGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -32,149 +32,148 @@ type Outcome = | { readonly _tag: "text"; readonly text: string } | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; -const runOnServer = ( +const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* ( connection: OpenCode2Server.OpenCode2Connection, input: TextGenerationOperations.Request, -) => - Effect.gen(function* () { - const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost; - const { client } = connection; - const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); - if (parsed === null) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode model selection must use the 'provider/model' format.", - }); - } - const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); - const outcome = yield* Deferred.make(); - let sessionId: string | undefined; - const texts = new Map(); - // Subscribed first: the stream is volatile, and the reply arrives on it. - const events = yield* connection.events; - yield* events.pipe( - Stream.runForEach((event) => { - // An execution end a newer server sent in a shape this build cannot - // read still ends the generation, as it ends a turn in the adapter. - if (event.type === "unreadable.execution.ended") { - if (event.sessionID !== sessionId) return Effect.void; - return Deferred.succeed( - outcome, - event.executionType === "session.execution.succeeded" - ? { _tag: "text", text: [...texts.values()].join("\n").trim() } - : { - _tag: "failed", - detail: "OpenCode ended the generation in a way this version cannot read.", - cause: event, - }, - ); - } - if ( - sessionId === undefined || - !("data" in event) || - !("sessionID" in event.data) || - event.data.sessionID !== sessionId - ) { +) { + const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost; + const { client } = connection; + const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); + if (parsed === null) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const outcome = yield* Deferred.make(); + let sessionId: string | undefined; + const texts = new Map(); + // Subscribed first: the stream is volatile, and the reply arrives on it. + const events = yield* connection.events; + yield* events.pipe( + Stream.runForEach((event) => { + // An execution end a newer server sent in a shape this build cannot + // read still ends the generation, as it ends a turn in the adapter. + if (event.type === "unreadable.execution.ended") { + if (event.sessionID !== sessionId) return Effect.void; + return Deferred.succeed( + outcome, + event.executionType === "session.execution.succeeded" + ? { _tag: "text", text: [...texts.values()].join("\n").trim() } + : { + _tag: "failed", + detail: "OpenCode ended the generation in a way this version cannot read.", + cause: event, + }, + ); + } + if ( + sessionId === undefined || + !("data" in event) || + !("sessionID" in event.data) || + event.data.sessionID !== sessionId + ) { + return Effect.void; + } + switch (event.type) { + case "session.text.ended": + texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text); return Effect.void; - } - switch (event.type) { - case "session.text.ended": - texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text); - return Effect.void; - case "permission.asked": - return client.permission - .reply({ - sessionID: event.data.sessionID, - requestID: event.data.id, - decision: "reject", - message: "Tools are not available while generating text.", - }) - .pipe(Effect.ignore({ log: true })); - case "session.execution.succeeded": - return Deferred.succeed(outcome, { - _tag: "text", - text: [...texts.values()].join("\n").trim(), - }); - case "session.execution.failed": - return Deferred.succeed(outcome, { - _tag: "failed", - detail: "OpenCode could not generate the text.", - cause: event.data.error, - }); - case "session.execution.interrupted": - return Deferred.succeed(outcome, { - _tag: "failed", - detail: "OpenCode stopped the generation.", - }); - default: - return Effect.void; - } - }), - // The reply only arrives on this stream, so a lost stream ends the wait. - Effect.exit, - Effect.flatMap((exit) => - Deferred.succeed(outcome, { - _tag: "failed", - detail: "The OpenCode event stream was lost.", - cause: exit, - }), - ), - Effect.forkScoped, - ); - const session = yield* client.session.create({ - title: `T3 Code ${input.operation}`, - location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), - model: Model.Ref.make({ - providerID: Provider.ID.make(parsed.providerID), - id: Model.ID.make(parsed.modelID), - ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + case "permission.asked": + return client.permission + .reply({ + sessionID: event.data.sessionID, + requestID: event.data.id, + decision: "reject", + message: "Tools are not available while generating text.", + }) + .pipe(Effect.ignore({ log: true })); + case "session.execution.succeeded": + return Deferred.succeed(outcome, { + _tag: "text", + text: [...texts.values()].join("\n").trim(), + }); + case "session.execution.failed": + return Deferred.succeed(outcome, { + _tag: "failed", + detail: "OpenCode could not generate the text.", + cause: event.data.error, + }); + case "session.execution.interrupted": + return Deferred.succeed(outcome, { + _tag: "failed", + detail: "OpenCode stopped the generation.", + }); + default: + return Effect.void; + } + }), + // The reply only arrives on this stream, so a lost stream ends the wait. + Effect.exit, + Effect.flatMap((exit) => + Deferred.succeed(outcome, { + _tag: "failed", + detail: "The OpenCode event stream was lost.", + cause: exit, }), - permissions: GENERATION_PERMISSIONS, - }); - sessionId = session.id; - yield* Effect.addFinalizer(() => - client.session - .remove({ sessionID: session.id }) - .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), - ); - const images = (input.attachments ?? []).flatMap((attachment) => { - if (attachment.type !== "image") return []; - const path = resolveAttachmentPath(attachment); - return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; + ), + Effect.forkScoped, + ); + const session = yield* client.session.create({ + title: `T3 Code ${input.operation}`, + location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), + model: Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }), + permissions: GENERATION_PERMISSIONS, + }); + sessionId = session.id; + yield* Effect.addFinalizer(() => + client.session + .remove({ sessionID: session.id }) + .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), + ); + const images = (input.attachments ?? []).flatMap((attachment) => { + if (attachment.type !== "image") return []; + const path = resolveAttachmentPath(attachment); + return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; + }); + yield* client.session.prompt({ + sessionID: session.id, + text: input.prompt, + ...(images.length === 0 ? {} : { files: images }), + }); + const result = yield* Deferred.await(outcome).pipe( + Effect.timeoutOrElse({ + duration: GENERATION_TIMEOUT, + orElse: () => + client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( + Effect.ignore, + Effect.as({ + _tag: "failed", + detail: "OpenCode did not finish generating in time.", + }), + ), + }), + ); + if (result._tag === "failed") { + return yield* new TextGenerationError({ + operation: input.operation, + detail: result.detail, + ...(result.cause === undefined ? {} : { cause: result.cause }), }); - yield* client.session.prompt({ - sessionID: session.id, - text: input.prompt, - ...(images.length === 0 ? {} : { files: images }), + } + if (result.text.length === 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned empty output.", }); - const result = yield* Deferred.await(outcome).pipe( - Effect.timeoutOrElse({ - duration: GENERATION_TIMEOUT, - orElse: () => - client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( - Effect.ignore, - Effect.as({ - _tag: "failed", - detail: "OpenCode did not finish generating in time.", - }), - ), - }), - ); - if (result._tag === "failed") { - return yield* new TextGenerationError({ - operation: input.operation, - detail: result.detail, - ...(result.cause === undefined ? {} : { cause: result.cause }), - }); - } - if (result.text.length === 0) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned empty output.", - }); - } - return result.text; - }).pipe(Effect.scoped); + } + return result.text; +}, Effect.scoped); export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { const server = yield* OpenCode2Server.OpenCode2Server; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index bf3d88bcf571..4a8f34c821dc 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -582,11 +582,12 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); - const updateProviderSession = ( - status: OrchestrationV2ProviderSession["status"], - lastError: string | null = sessionEntity.lastError, - ) => - Effect.gen(function* () { + const updateProviderSession = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) { const updatedAt = yield* DateTime.now; sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; yield* emit({ @@ -596,12 +597,13 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); }); - const updateProviderThread = ( - state: PiThreadState, - patch: Partial, - retainedNativeTurnIds?: ReadonlyArray, - ) => - Effect.gen(function* () { + const updateProviderThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + state: PiThreadState, + patch: Partial, + retainedNativeTurnIds?: ReadonlyArray, + ) { const updatedAt = yield* DateTime.now; state.providerThread = { ...state.providerThread, ...patch, updatedAt }; yield* emit({ @@ -692,8 +694,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( /** * Pi attaches the current message's cumulative usage to every streaming update (0.84.2+). */ - const reportLiveUsage = (turn: ActivePiTurn, usage: unknown) => - Effect.gen(function* () { + const reportLiveUsage = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (turn: ActivePiTurn, usage: unknown) { const usedTokens = nonNegativeInteger(usage, "totalTokens"); if ( usedTokens === undefined || @@ -849,8 +852,13 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); }); - const emitStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, streaming: boolean) => - Effect.gen(function* () { + const emitStreamItem = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + turn: ActivePiTurn, + item: PiStreamItemState, + streaming: boolean, + ) { const emittedAt = yield* DateTime.now; const base = baseItemFields(turn, item.nativeItemId, item.startedAt, emittedAt); yield* emitItemNode( @@ -918,8 +926,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); }); - const scheduleStreamFlush = (turn: ActivePiTurn, item: PiStreamItemState) => - Effect.gen(function* () { + const scheduleStreamFlush = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (turn: ActivePiTurn, item: PiStreamItemState) { if (item.flushScheduled || item.completed) return; item.flushScheduled = true; yield* Effect.sleep(Duration.millis(STREAM_FLUSH_MS)).pipe( @@ -1159,52 +1168,55 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } }); - const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => - Effect.gen(function* () { - if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); - yield* connection - .send({ - type: "extension_ui_response", - id: pending.nativeRequestId, - cancelled: true, - }) - .pipe(Effect.ignore); - pending.runtimeRequest = { - ...pending.runtimeRequest, + const cancelPrompt = Effect.fn("PiAdapter.cancelPrompt")(function* ( + pending: PendingPiPrompt, + resolvedAt: DateTime.Utc, + ) { + if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); + yield* connection + .send({ + type: "extension_ui_response", + id: pending.nativeRequestId, + cancelled: true, + }) + .pipe(Effect.ignore); + pending.runtimeRequest = { + ...pending.runtimeRequest, + status: "cancelled", + resolvedAt, + }; + yield* emit({ + type: "runtime_request.updated", + driver: PI_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: PI_PROVIDER, + node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...pending.turnItem, status: "cancelled", - resolvedAt, - }; - yield* emit({ - type: "runtime_request.updated", - driver: PI_PROVIDER, - threadId: pending.node.threadId, - runtimeRequest: pending.runtimeRequest, - }); - yield* emit({ - type: "node.updated", - driver: PI_PROVIDER, - node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, - }); - yield* emit({ - type: "turn_item.updated", - driver: PI_PROVIDER, - turnItem: { - ...pending.turnItem, - status: "cancelled", - completedAt: resolvedAt, - updatedAt: resolvedAt, - }, - }); + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, }); + }); - const cancelPendingPrompts = (resolvedAt: DateTime.Utc) => - Effect.gen(function* () { - const pending = Array.from(pendingPrompts.values()); - pendingPrompts.clear(); - yield* Effect.forEach(pending, (prompt) => cancelPrompt(prompt, resolvedAt), { - discard: true, - }); + const cancelPendingPrompts = Effect.fn("PiAdapter.cancelPendingPrompts")(function* ( + resolvedAt: DateTime.Utc, + ) { + const pending = Array.from(pendingPrompts.values()); + pendingPrompts.clear(); + yield* Effect.forEach(pending, (prompt) => cancelPrompt(prompt, resolvedAt), { + discard: true, }); + }); const handleExtensionUiRequest = Effect.fnUntraced(function* (event: PiRpcRecord) { const method = recordString(event, "method"); @@ -2409,8 +2421,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ...turnInput, message: { ...turnInput.message, text: "/compact" }, }), - startTurn: (turnInput) => - Effect.gen(function* () { + startTurn: Effect.fn("PiAdapter.startTurn")( + function* (turnInput) { if (rollbackBarrier !== null) return yield* protocolError("Cannot start a Pi turn during rollback"); const state = threadState; @@ -2585,20 +2597,23 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // and extension commands may block on user dialogs indefinitely. // Rejections therefore return later as id-less response records // handled by the event pump. - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: PI_PROVIDER, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: PI_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), ), - ), - steerTurn: (steerInput: ProviderAdapter.ProviderAdapterV2SteerInput) => - Effect.gen(function* () { + ), + steerTurn: Effect.fn("PiAdapter.steerTurn")( + function* (steerInput: ProviderAdapter.ProviderAdapterV2SteerInput) { const turn = threadState?.activeTurn ?? null; if (turn === null || turn.providerTurn.id !== steerInput.providerTurnId) { return yield* protocolError(`Pi turn ${steerInput.providerTurnId} is not active`); @@ -2645,19 +2660,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( turn.settleProbeGeneration += 1; }), ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterSteerRunError({ - driver: PI_PROVIDER, - providerThreadId: steerInput.providerThread.id, - providerTurnId: steerInput.providerTurnId, - cause, - }), + }, + (effect, steerInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver: PI_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), ), - ), - interruptTurn: (interruptInput) => - Effect.gen(function* () { + ), + interruptTurn: Effect.fn("PiAdapter.interruptTurn")( + function* (interruptInput) { const turn = threadState?.activeTurn ?? null; if (turn === null && interruptInput.requestRuntimeRestart === true) { if (pendingWake !== null) @@ -2697,19 +2715,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* request({ type: "abort" }).pipe( Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: PI_PROVIDER, - providerThreadId: interruptInput.providerThread.id, - providerTurnId: interruptInput.providerTurnId, - cause, - }), + }, + (effect, interruptInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: PI_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), ), - ), - respondToRuntimeRequest: (requestInput) => - Effect.gen(function* () { + ), + respondToRuntimeRequest: Effect.fn("PiAdapter.respondToRuntimeRequest")( + function* (requestInput) { const pending = pendingPrompts.get(String(requestInput.requestId)); if (pending === undefined) { return yield* protocolError( @@ -2765,19 +2786,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( updatedAt: resolvedAt, }, }); - }).pipe( - sessionEventPermit.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver: PI_PROVIDER, - requestId: requestInput.requestId, - cause, - }), + }, + (effect, requestInput) => + effect.pipe( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: PI_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), ), - ), - readThreadSnapshot: (snapshotInput) => - Effect.gen(function* () { + ), + readThreadSnapshot: Effect.fn("PiAdapter.readThreadSnapshot")( + function* (snapshotInput) { const state = threadState; const boundNativeId = state?.providerThread.nativeThreadRef?.nativeId; const wantedNativeId = snapshotInput.providerThread.nativeThreadRef?.nativeId; @@ -2830,18 +2854,21 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( messages, runtimeRequests: [], }; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver: PI_PROVIDER, - providerThreadId: snapshotInput.providerThread.id, - cause, - }), + }, + (effect, snapshotInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: PI_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), ), - ), - rollbackThread: (rollbackInput) => - Effect.gen(function* () { + ), + rollbackThread: Effect.fn("PiAdapter.rollbackThread")( + function* (rollbackInput) { const state = threadState; if (state === null) { return yield* protocolError("Pi session has no registered thread"); @@ -2952,19 +2979,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (rollbackBarrier === barrier) rollbackBarrier = null; }), ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: PI_PROVIDER, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause, - }), + }, + (effect, rollbackInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: PI_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), ), - ), - forkThread: (forkInput) => - Effect.gen(function* () { + ), + forkThread: Effect.fn("PiAdapter.forkThread")( + function* (forkInput) { if (threadState?.activeTurn != null) { return yield* protocolError("Cannot fork while a Pi turn is active"); } @@ -3063,16 +3093,19 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }, false, ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterForkThreadError({ - driver: PI_PROVIDER, - providerThreadId: forkInput.sourceProviderThread.id, - cause, - }), + }, + (effect, forkInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: PI_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), ), - ), + ), }; return runtime; }), diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index 42e240fdc7b1..e48700ff6af4 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -85,131 +85,137 @@ export const PiDriver: ProviderDriver = { usage: piUsageReader, configSchema: PiSettings, defaultConfig: (): PiSettings => decodePiSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const host = yield* ProviderHost.ProviderHost; - const { cwd } = host.paths; - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies PiSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ), - ); - - const orchestrationAdapter = yield* PiAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, + create: Effect.fn("PiDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const host = yield* ProviderHost.ProviderHost; + const { cwd } = host.paths; + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies PiSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Pi orchestration adapter.", - cause, - }), - ), - ); - const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); - - const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv, cwd).pipe( - Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ), + ); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichPiSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Pi snapshot.", - cause, + const orchestrationAdapter = yield* PiAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Pi orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichPiSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), ), - ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Pi snapshot.", + cause, + }), + ), + ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - snapshotForCwd: (workspaceCwd) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - discoverPiCommandsForCwd(effectiveConfig, processEnv, workspaceCwd).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to discover Pi workspace commands.", - cause, - }), - ), + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd: (workspaceCwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverPiCommandsForCwd(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to discover Pi workspace commands.", + cause, + }), ), - ]).pipe( - Effect.map(([machineSnapshot, commands]) => ({ - ...machineSnapshot, - ...commands, - slashCommandsPending: false, - })), ), - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), + ]).pipe( + Effect.map(([machineSnapshot, commands]) => ({ + ...machineSnapshot, + ...commands, + slashCommandsPending: false, + })), + ), + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), }; diff --git a/packages/provider-pi/src/server/rpc.ts b/packages/provider-pi/src/server/rpc.ts index aabce9e8ca34..c36a231051ed 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -167,14 +167,16 @@ function parsePiRecord(line: string): PiRpcRecord | undefined { * gone its pid/pgid can be recycled by the OS, so escalating blindly could * deliver SIGKILL to an unrelated process. */ -const terminatePiProcess = (kill: (signal: NodeJS.Signals) => boolean, hasExited: () => boolean) => - Effect.gen(function* () { - if (hasExited()) return; - if (!kill("SIGTERM")) return; - yield* Effect.sleep(TERMINATION_GRACE); - if (hasExited()) return; - kill("SIGKILL"); - }); +const terminatePiProcess = Effect.fn("PiRpc.terminatePiProcess")(function* ( + kill: (signal: NodeJS.Signals) => boolean, + hasExited: () => boolean, +) { + if (hasExited()) return; + if (!kill("SIGTERM")) return; + yield* Effect.sleep(TERMINATION_GRACE); + if (hasExited()) return; + kill("SIGKILL"); +}); export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSpawnOptions) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -251,20 +253,20 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp const exitDeferred = yield* Deferred.make(); let nextRequestId = 0; - const failTransport = (error: PiRpcError) => - Effect.gen(function* () { - const claimed = yield* Deferred.fail(transportDown, error); - if (!claimed) return; - for (const [key, pending] of pendingRequests) { - pendingRequests.delete(key); - yield* Deferred.fail(pending.deferred, error); - } - yield* Queue.fail(outgoing, error); - yield* Queue.fail(events, error); - }); + const failTransport = Effect.fn("PiRpc.failTransport")(function* (error: PiRpcError) { + const claimed = yield* Deferred.fail(transportDown, error); + if (!claimed) return; + for (const [key, pending] of pendingRequests) { + pendingRequests.delete(key); + yield* Deferred.fail(pending.deferred, error); + } + yield* Queue.fail(outgoing, error); + yield* Queue.fail(events, error); + }); - const routeRecord = (record: PiRpcRecord) => - Effect.gen(function* () { + const routeRecord = + /* Protocol records and writes include streaming deltas; avoid a span per record. */ + Effect.fnUntraced(function* (record: PiRpcRecord) { if (record["type"] === "response" && typeof record["id"] === "string") { const pending = pendingRequests.get(record["id"]); if (pending !== undefined) { @@ -386,8 +388,9 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp Effect.forkIn(scope), ); - const send = (record: PiRpcRecord): Effect.Effect => - Effect.gen(function* () { + const send = + /* Protocol records and writes include streaming deltas; avoid a span per record. */ + Effect.fnUntraced(function* (record: PiRpcRecord): Effect.fn.Return { const accepted = yield* Queue.offer( outgoing, new TextEncoder().encode(`${encodeJsonLine(record)}\n`), @@ -397,32 +400,31 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp } }); - const request = ( + const request = Effect.fn("PiRpc.request")(function* ( record: PiRpcRecord, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - ): Effect.Effect => - Effect.gen(function* () { - const id = `t3-${nextRequestId++}`; - const deferred = yield* Deferred.make(); - pendingRequests.set(id, { deferred }); - yield* send({ ...record, id }).pipe( - Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), - ); - return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( - Effect.timeoutOrElse({ - duration: Duration.millis(timeoutMs), - orElse: () => - Effect.fail( - new PiRpcTimeoutError({ - operation: String(record["type"] ?? "request"), - timeoutMs, - }), - ), - }), - Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(id))), - Effect.onError(() => Effect.sync(() => pendingRequests.delete(id))), - ); - }); + ): Effect.fn.Return { + const id = `t3-${nextRequestId++}`; + const deferred = yield* Deferred.make(); + pendingRequests.set(id, { deferred }); + yield* send({ ...record, id }).pipe( + Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), + ); + return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => + Effect.fail( + new PiRpcTimeoutError({ + operation: String(record["type"] ?? "request"), + timeoutMs, + }), + ), + }), + Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(id))), + Effect.onError(() => Effect.sync(() => pendingRequests.delete(id))), + ); + }); return { send, diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 7e964389c318..3030200c245a 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -156,28 +156,27 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( return connection; }); -const discoverPiViaRpc = ( +const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv, launchArgs: ReadonlyArray, cwd?: string, -) => - Effect.gen(function* () { - const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); - const stateData = yield* connection.request({ type: "get_state" }); - const modelsData = yield* connection.request({ type: "get_available_models" }); - const commandsData = yield* connection - .request({ type: "get_commands" }) - .pipe(Effect.orElseSucceed(() => undefined)); - const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); - const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); - return { - models: discoveredModels, - slashCommands: withPiBuiltinSlashCommands(slashCommands), - skills, - authenticated: discoveredModels.length > 0, - } satisfies PiDiscovery; - }).pipe(Effect.scoped); +) { + const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); + const stateData = yield* connection.request({ type: "get_state" }); + const modelsData = yield* connection.request({ type: "get_available_models" }); + const commandsData = yield* connection + .request({ type: "get_commands" }) + .pipe(Effect.orElseSucceed(() => undefined)); + const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); + const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); + return { + models: discoveredModels, + slashCommands: withPiBuiltinSlashCommands(slashCommands), + skills, + authenticated: discoveredModels.length > 0, + } satisfies PiDiscovery; +}, Effect.scoped); export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( function* (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd: string) { @@ -206,57 +205,57 @@ export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( }), ); -const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => - Effect.gen(function* () { - const command = piSettings.binaryPath || "pi"; - const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { - env: environment, - }); - return yield* spawnAndCollect( - command, - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: environment, - shell: spawnCommand.shell, - }), - ); +const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, +) { + const command = piSettings.binaryPath || "pi"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); -export function buildInitialPiProviderSnapshot( - piSettings: PiSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = piModelsFromSettings(piSettings.customModels); - if (!piSettings.enabled) { - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Pi is disabled in T3 Code settings.", - }, - }); - } +export const buildInitialPiProviderSnapshot = Effect.fn( + "PiProviderStatus.buildInitialPiProviderSnapshot", +)(function* (piSettings: PiSettings): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = piModelsFromSettings(piSettings.customModels); + if (!piSettings.enabled) { return buildServerProvider({ presentation: PI_PRESENTATION, - enabled: true, + enabled: false, checkedAt, models, probe: { - installed: true, + installed: false, version: null, status: "warning", auth: { status: "unknown" }, - message: "Checking Pi CLI availability...", + message: "Pi is disabled in T3 Code settings.", }, }); + } + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Pi CLI availability...", + }, }); -} +}); export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( piSettings: PiSettings, diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index d36770a40d7d..29bf67c3d592 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -21,9 +21,10 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runPiJson: TextGenerationOperations.Runner = (request) => { - const { operation, cwd, prompt, modelSelection } = request; - return Effect.gen(function* () { + const runPiJson: TextGenerationOperations.Runner = Effect.fn("PiTextGeneration.runPiJson")( + function* (request) { + const { operation, cwd, prompt, modelSelection } = request; + const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); if (!resolvedLaunchArgs.ok) { return yield* new TextGenerationError({ @@ -87,27 +88,31 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* }); } return yield* TextGenerationOperations.decodeJsonReply(request, "Pi", text); - }).pipe( - Effect.timeoutOption(PI_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail(new TextGenerationError({ operation, detail: "Pi request timed out." })), - onSome: (value) => Effect.succeed(value), - }), - ), - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Pi text generation failed.", - cause, - }), - ), - Effect.scoped, - ); - }; + }, + (effect, request) => { + const { operation } = request; + return effect.pipe( + Effect.timeoutOption(PI_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new TextGenerationError({ operation, detail: "Pi request timed out." })), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Pi text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + }, + ); return TextGenerationOperations.fromRunner( "PiTextGeneration", From 509e0e054905442c7933cf1ca25c67b7982581a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:30:59 -0700 Subject: [PATCH 005/458] refactor(server): trace provider operations and keep streamed events untraced (#21993) --- .../Adapters/AntigravityAdapterV2.ts | 44 +- .../Adapters/ClaudeAdapterV2.ts | 144 +- .../Adapters/CodexAdapterV2.ts | 2316 +++++++++-------- apps/server/src/provider/AntigravityAuth.ts | 54 +- apps/server/src/provider/ClaudeProvider.ts | 13 +- apps/server/src/provider/CodexChatGptAuth.ts | 77 +- apps/server/src/provider/CodexProvider.ts | 47 +- .../src/provider/Drivers/AntigravityDriver.ts | 801 +++--- .../src/provider/Drivers/ClaudeDriver.ts | 531 ++-- .../src/provider/Drivers/CodexDriver.ts | 446 ++-- .../Drivers/antigravityUsageReader.ts | 78 +- .../src/provider/ProviderAuthService.ts | 9 +- .../src/provider/ProviderInstanceRegistry.ts | 240 +- apps/server/src/provider/ProviderRegistry.ts | 31 +- .../server/src/provider/claudeResetCredits.ts | 39 +- .../providerMaintenanceCommandCoordinator.ts | 14 +- .../src/provider/providerStatusCache.ts | 7 +- .../src/provider/resetCreditCoordinator.ts | 37 +- .../provider/unavailableProviderSnapshot.ts | 64 +- 19 files changed, 2541 insertions(+), 2451 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 62b2ec6eac11..fb7130ed1639 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -120,8 +120,8 @@ export function makeAntigravityAcpAdapterFlavor( ): AcpAdapterV2Flavor { const antigravityClientFileRoots = (cwd: string | null) => cwd === null ? [options.host.paths.attachmentsDir] : [cwd, options.host.paths.attachmentsDir]; - const makeRuntime = (input: AcpAdapterV2RuntimeInput) => - Effect.gen(function* () { + const makeRuntime = Effect.fn("AntigravityAdapterV2.makeRuntime")( + function* (input: AcpAdapterV2RuntimeInput) { const scope = yield* Scope.fork(yield* Effect.scope); const runtime = yield* options .withProcess( @@ -144,14 +144,14 @@ export function makeAntigravityAcpAdapterFlavor( ), ), }; - }).pipe( - Effect.provideService(Crypto.Crypto, options.crypto), - Effect.mapError((cause): EffectAcpErrors.AcpError => - cause._tag === "ProviderSetupError" - ? new EffectAcpErrors.AcpTransportError({ detail: cause.detail, cause }) - : cause, - ), - ); + }, + Effect.provideService(Crypto.Crypto, options.crypto), + Effect.mapError((cause): EffectAcpErrors.AcpError => + cause._tag === "ProviderSetupError" + ? new EffectAcpErrors.AcpTransportError({ detail: cause.detail, cause }) + : cause, + ), + ); return { driver: ANTIGRAVITY_PROVIDER, @@ -163,17 +163,19 @@ export function makeAntigravityAcpAdapterFlavor( preferResumeSession: true, subagentsIdleOnTurnCompletion: true, ...(options.onSessionEvent === undefined ? {} : { onSessionEvent: options.onSessionEvent }), - applyModelSelection: ({ runtime, modelSelection }) => - Effect.gen(function* () { - const defaultModel = yield* options.defaultModel; - return yield* applyAntigravityAcpModelSelection({ - runtime, - model: - modelSelection.model === ANTIGRAVITY_DEFAULT_MODEL ? undefined : modelSelection.model, - defaultModel, - mapError: (cause) => cause, - }); - }), + applyModelSelection: Effect.fn("AntigravityAdapterV2.applyModelSelection")(function* ({ + runtime, + modelSelection, + }) { + const defaultModel = yield* options.defaultModel; + return yield* applyAntigravityAcpModelSelection({ + runtime, + model: + modelSelection.model === ANTIGRAVITY_DEFAULT_MODEL ? undefined : modelSelection.model, + defaultModel, + mapError: (cause) => cause, + }); + }), sessionModeForPolicy: (policy) => antigravityPermissionMode(policy.runtimeMode), clientFileSystem: { readTextFile: (request, cwd) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b5f585ddbfaf..1e0b25eec2af 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3967,8 +3967,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - const rememberProviderThread = (providerThread: OrchestrationV2ProviderThread) => - Effect.gen(function* () { + const rememberProviderThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (providerThread: OrchestrationV2ProviderThread) { const nativeThreadId = providerThread.nativeThreadRef?.nativeId; if (nativeThreadId === undefined || nativeThreadId === null) { return; @@ -4064,11 +4065,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.map((all) => taskIdSetForNativeThread(all, nativeThreadId).has(taskId)), ); - const isKnownOpaqueBackgroundTaskOnNativeThread = ( - nativeThreadId: string, - taskId: string, - ) => - Effect.gen(function* () { + const isKnownOpaqueBackgroundTaskOnNativeThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeThreadId: string, taskId: string) { if (yield* hasPendingBackgroundTaskOnNativeThread(nativeThreadId, taskId)) { return true; } @@ -4091,11 +4090,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( taskIds, ); - const consumeWakeEligibilityForBufferedNotification = ( - nativeThreadId: string, - taskId: string, - ) => - Effect.gen(function* () { + const consumeWakeEligibilityForBufferedNotification = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeThreadId: string, taskId: string) { yield* clearTaskIdFromNativeThreadSet( wakeEligibleBackgroundTasksByNativeThread, nativeThreadId, @@ -4186,11 +4183,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return updated; }); - const replacePendingBackgroundTasks = ( - nativeThreadId: string, - tasks: ReadonlyArray, - ) => - Effect.gen(function* () { + const replacePendingBackgroundTasks = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + nativeThreadId: string, + tasks: ReadonlyArray, + ) { yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => { const updated = new Map(current); if (tasks.length === 0) { @@ -4212,11 +4210,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - const upsertPendingBackgroundTask = ( - nativeThreadId: string, - task: OrchestrationV2PendingBackgroundTask, - ) => - Effect.gen(function* () { + const upsertPendingBackgroundTask = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + nativeThreadId: string, + task: OrchestrationV2PendingBackgroundTask, + ) { yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => { const roster = new Map(rosterForNativeThread(current, nativeThreadId)); roster.set(task.taskId, task); @@ -4243,8 +4242,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return [true, updated] as const; }); - const clearPendingBackgroundTasksForNativeThread = (nativeThreadId: string) => - Effect.gen(function* () { + const clearPendingBackgroundTasksForNativeThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeThreadId: string) { yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => { if (!current.has(nativeThreadId)) { return current; @@ -4256,8 +4256,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId); }); - const clearWakeStateForNativeThread = (nativeThreadId: string) => - Effect.gen(function* () { + const clearWakeStateForNativeThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeThreadId: string) { yield* clearWakeReports(nativeThreadId); yield* Ref.update(wakeBuffers, (current) => { if (!current.has(nativeThreadId)) { @@ -6083,8 +6084,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const reasoningDeltas = yield* makeProviderTextDeltaCoalescer({ flushIntervalMs: 50, - emit: (update) => - Effect.gen(function* () { + emit: + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (update) { const context = yield* Ref.get(activeTurn); if (context === null || context.nativeTurnId !== update.turnId) return; const block = context.reasoning.blocks.get(update.itemId); @@ -9467,19 +9469,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } return false; }), - hasPendingBackgroundWorkForThread: (providerThread) => - Effect.gen(function* () { - const nativeThreadId = providerThread.nativeThreadRef?.nativeId; - if (nativeThreadId === undefined || nativeThreadId === null) { - return false; - } - return ( - rosterForNativeThread( - yield* Ref.get(pendingBackgroundTasksByNativeThread), - nativeThreadId, - ).size > 0 - ); - }), + hasPendingBackgroundWorkForThread: Effect.fn( + "ClaudeAdapterV2.hasPendingBackgroundWorkForThread", + )(function* (providerThread) { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId === undefined || nativeThreadId === null) { + return false; + } + return ( + rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ).size > 0 + ); + }), ensureThread: Effect.fn("ClaudeAdapterV2.ensureThread")( function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { const createdAt = yield* DateTime.now; @@ -9551,36 +9554,35 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message: { ...turnInput.message, text: "/compact" }, }), steerTurn, - stopTask: ({ providerThread, taskId }) => - Effect.gen(function* () { - const liveQuery = yield* Ref.get(queryContext); - const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); - if ( - liveQuery === null || - liveQuery.stopping || - liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId || - task === undefined || - task.task.threadId !== providerThread.appThreadId || - task.task.workflow === undefined || - task.task.status !== "running" || - liveQuery.subagentsFromEarlierProcesses.has(task) - ) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CLAUDE_PROVIDER, - detail: "No running workflow task belongs to this provider thread", - }); - } - yield* liveQuery.query.stopTask(taskId).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CLAUDE_PROVIDER, - detail: "Workflow stop request failed", - cause, - }), - ), - ); - }), + stopTask: Effect.fn("ClaudeAdapterV2.stopTask")(function* ({ providerThread, taskId }) { + const liveQuery = yield* Ref.get(queryContext); + const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); + if ( + liveQuery === null || + liveQuery.stopping || + liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId || + task === undefined || + task.task.threadId !== providerThread.appThreadId || + task.task.workflow === undefined || + task.task.status !== "running" || + liveQuery.subagentsFromEarlierProcesses.has(task) + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "No running workflow task belongs to this provider thread", + }); + } + yield* liveQuery.query.stopTask(taskId).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Workflow stop request failed", + cause, + }), + ), + ); + }), interruptTurn, stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { const existing = yield* Ref.get(queryContext); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index d26273081b29..4750aad53269 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -717,19 +717,19 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { } } -export function buildCodexTurnStartParams(input: { - readonly nativeThreadId: string; - readonly codexInput: ReadonlyArray; - readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - readonly modelSelection: ModelSelection; - readonly hasT3Mcp?: boolean; - readonly browserToolsAvailable?: boolean; - readonly deviceToolsAvailable?: boolean; - /** ChatGPT token sharing does not accept service tiers. */ - readonly omitServiceTier?: boolean; - readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; -}) { - return Effect.gen(function* () { +export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTurnStartParams")( + function* (input: { + readonly nativeThreadId: string; + readonly codexInput: ReadonlyArray; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly modelSelection: ModelSelection; + readonly hasT3Mcp?: boolean; + readonly browserToolsAvailable?: boolean; + readonly deviceToolsAvailable?: boolean; + /** ChatGPT token sharing does not accept service tiers. */ + readonly omitServiceTier?: boolean; + readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; + }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); const approvalPolicy = input.runtimePolicy.approvalPolicy === undefined @@ -808,8 +808,8 @@ export function buildCodexTurnStartParams(input: { ...(serviceTier === undefined ? {} : { serviceTier }), ...(collaborationMode === undefined ? {} : { collaborationMode }), }); - }); -} + }, +); function providerSession(input: { readonly providerSessionId: OrchestrationV2ProviderSession["id"]; @@ -832,17 +832,17 @@ function providerSession(input: { }; } -function getNativeThreadId(providerThread: OrchestrationV2ProviderThread) { - return Effect.gen(function* () { - const nativeThreadId = providerThread.nativeThreadRef?.nativeId; - if (nativeThreadId === undefined || nativeThreadId === null) { - return yield* toProtocolError( - `Provider thread ${providerThread.id} is missing a native Codex thread id.`, - ); - } - return nativeThreadId; - }); -} +const getNativeThreadId = Effect.fn("CodexAdapterV2.getNativeThreadId")(function* ( + providerThread: OrchestrationV2ProviderThread, +) { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId === undefined || nativeThreadId === null) { + return yield* toProtocolError( + `Provider thread ${providerThread.id} is missing a native Codex thread id.`, + ); + } + return nativeThreadId; +}); function providerThreadFromCodexThread(input: { readonly appThreadId: ThreadId | null; @@ -1399,31 +1399,30 @@ const layerCodexAppServerClientFactoryCommand = ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return CodexAppServerClientFactory.of({ - open: (input) => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const command = yield* makeCodexAppServerSpawnCommand({ - command: options.command, - args: [...(options.args ?? [])], - ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - ...(options.env === undefined ? {} : { env: options.env, extendEnv: true }), - }); - const handle = yield* spawner.spawn(command).pipe( - Effect.provideService(Scope.Scope, scope), - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CODEX_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), - ), - ); - const context = yield* Layer.build(CodexClient.layerChildProcess(handle, options)); - return yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(context), - ); - }), + open: Effect.fn("CodexAdapterV2.open")(function* (input) { + const scope = yield* Scope.Scope; + const command = yield* makeCodexAppServerSpawnCommand({ + command: options.command, + args: [...(options.args ?? [])], + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.env === undefined ? {} : { env: options.env, extendEnv: true }), + }); + const handle = yield* spawner.spawn(command).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + const context = yield* Layer.build(CodexClient.layerChildProcess(handle, options)); + return yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(context), + ); + }), }); }), ); @@ -1514,50 +1513,49 @@ export const layerAppServerClientFactory: Layer.Layer< const agentScope = yield* AgentScope; return CodexAppServerClientFactory.of({ - open: (input) => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const environment = { - ...codexAppServerEnvironment(input.environment), - ...(input.settings.homePath ? { CODEX_HOME: input.settings.homePath } : {}), - }; - const command = yield* makeCodexAppServerSpawnCommand({ - command: input.settings.binaryPath || "codex", - args: codexAppServerArgs( - resolveCodexLaunchArgs(input.settings.launchArgs, input.environment), - ), - env: environment, - threadId: input.threadId, - }).pipe(Effect.provideService(AgentScope, agentScope)); - const handle = yield* spawner.spawn(command).pipe( - Effect.provideService(Scope.Scope, scope), - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CODEX_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), - ), - ); - const protocolLogger = makeCodexAppServerProtocolLogger({ - nativeEventLogger, - threadId: input.threadId, - providerSessionId: input.providerSessionId, - }); - const clientOptions: CodexClient.CodexAppServerClientOptions = - protocolLogger === undefined - ? {} - : { - logIncoming: true, - logOutgoing: true, - logger: protocolLogger, - }; - const context = yield* Layer.build(CodexClient.layerChildProcess(handle, clientOptions)); - return yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(context), - ); - }), + open: Effect.fn("CodexAdapterV2.open")(function* (input) { + const scope = yield* Scope.Scope; + const environment = { + ...codexAppServerEnvironment(input.environment), + ...(input.settings.homePath ? { CODEX_HOME: input.settings.homePath } : {}), + }; + const command = yield* makeCodexAppServerSpawnCommand({ + command: input.settings.binaryPath || "codex", + args: codexAppServerArgs( + resolveCodexLaunchArgs(input.settings.launchArgs, input.environment), + ), + env: environment, + threadId: input.threadId, + }).pipe(Effect.provideService(AgentScope, agentScope)); + const handle = yield* spawner.spawn(command).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + const protocolLogger = makeCodexAppServerProtocolLogger({ + nativeEventLogger, + threadId: input.threadId, + providerSessionId: input.providerSessionId, + }); + const clientOptions: CodexClient.CodexAppServerClientOptions = + protocolLogger === undefined + ? {} + : { + logIncoming: true, + logOutgoing: true, + logger: protocolLogger, + }; + const context = yield* Layer.build(CodexClient.layerChildProcess(handle, clientOptions)); + return yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(context), + ); + }), }); }), ); @@ -1571,52 +1569,51 @@ export type CodexAdapterV2DriverEnv = | Path.Path | ServerConfig; -export const createCodexAdapterV2 = ( +export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapterV2")(function* ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, hooks: Pick = {}, -) => - Effect.gen(function* () { - const clientFactory = yield* CodexAppServerClientFactory; - const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const hostEnvironment = yield* HostProcess.Environment; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig; - const homeLayout = yield* resolveCodexHomeLayout(config); - - yield* materializeCodexShadowHome(homeLayout).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterDriverCreateError({ - driver: CODEX_DRIVER_KIND, - instanceId, - detail: "Failed to materialize the Codex shadow home.", - cause, - }), - ), - ); - - const settings = { - ...config, - enabled, - binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), - homePath: homeLayout.effectiveHomePath ?? "", - } satisfies CodexSettings; +) { + const clientFactory = yield* CodexAppServerClientFactory; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const hostEnvironment = yield* HostProcess.Environment; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const serverConfig = yield* ServerConfig; + const homeLayout = yield* resolveCodexHomeLayout(config); + + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: CODEX_DRIVER_KIND, + instanceId, + detail: "Failed to materialize the Codex shadow home.", + cause, + }), + ), + ); - return yield* makeCodexAdapterV2({ - instanceId, - settings, - environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), - clientFactory, - crypto, - fileSystem, - idAllocator, - serverConfig, - continuationRequests, - ...hooks, - }); + const settings = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), + homePath: homeLayout.effectiveHomePath ?? "", + } satisfies CodexSettings; + + return yield* makeCodexAdapterV2({ + instanceId, + settings, + environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + clientFactory, + crypto, + fileSystem, + idAllocator, + serverConfig, + continuationRequests, + ...hooks, }); +}); export const CodexAdapterV2Driver: ProviderAdapterDriver = { driverKind: CODEX_DRIVER_KIND, @@ -1695,8 +1692,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), - openSession: (input) => - Effect.gen(function* () { + openSession: Effect.fn("CodexAdapterV2.openSession")( + function* (input) { const scope = yield* Scope.Scope; const resolvedRuntime = adapterOptions.resolveRuntime === undefined @@ -1727,8 +1724,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); // Codex drops client developer messages during compaction but only // resends additionalContext when it changes. - const restoreAdditionalContext = (threadId: string) => - Effect.gen(function* () { + const restoreAdditionalContext = Effect.fn("CodexAdapterV2.restoreAdditionalContext")( + function* (threadId: string) { const context = (yield* Ref.get(additionalContextByThread)).get(threadId); if (!context) return; yield* client.request("thread/inject_items", { @@ -1752,14 +1749,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ), }); - }).pipe( - Effect.timeout("10 seconds"), - Effect.catch((cause) => - Effect.logWarning("Failed to restore Codex additional context after compaction.", { - cause, - }), - ), - ); + }, + Effect.timeout("10 seconds"), + Effect.catch((cause) => + Effect.logWarning("Failed to restore Codex additional context after compaction.", { + cause, + }), + ), + ); const initialized = yield* Ref.make(false); // Threads share this app-server, and Codex rejects a second // `initialize`. @@ -1981,102 +1978,101 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); }); - const registerRootTurn = (input: { + const registerRootTurn = Effect.fn("CodexAdapterV2.registerRootTurn")(function* (input: { readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput; readonly nativeTurnId: string; readonly startedAt: DateTime.Utc; readonly waitForNativeStart?: boolean; - }) => - Effect.gen(function* () { - const existing = (yield* Ref.get(activeTurns)).get(input.nativeTurnId); - if (existing !== undefined) { - return existing; - } - const providerTurnId = idAllocator.derive.providerTurn({ - driver: CODEX_PROVIDER, - nativeTurnId: input.nativeTurnId, - }); - const context: ActiveCodexTurnContext = { - ...(input.waitForNativeStart - ? { nativeStartReady: yield* Deferred.make() } - : {}), - input: input.turnInput, - projectionAppThread: input.turnInput.appThread, - projectionThreadId: input.turnInput.threadId, - projectionRunId: input.turnInput.runId, - nativeTurnId: input.nativeTurnId, - providerThread: input.turnInput.providerThread, - providerTurnId, - providerTurnOrdinal: input.turnInput.providerTurnOrdinal, - providerNodeId: input.turnInput.rootNodeId, - providerNodeKind: "root_turn", - providerNodeStartedAt: input.startedAt, - itemParentNodeId: input.turnInput.rootNodeId, - rootNodeId: input.turnInput.rootNodeId, - subagent: null, - startedAt: input.startedAt, - itemPositions: new Map(), - fileChangeKindsByItem: new Map(), - }; - yield* Ref.update(limitedTurnItems, (current) => { - const next = new Map(current); - next.delete(context.providerThread.id); - return next; - }); - beginTurnTokenUsage(context); - endSessionApprovalsOnModeChange(context); - yield* Ref.update(activeTurns, (current) => { - const updated = new Map(current); - updated.set(input.nativeTurnId, context); - return updated; - }); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CODEX_PROVIDER, - threadId: input.turnInput.threadId, - providerTurn: { - id: providerTurnId, - providerThreadId: input.turnInput.providerThread.id, - nodeId: input.turnInput.rootNodeId, - runAttemptId: input.turnInput.attemptId, - nativeTurnRef: { - driver: CODEX_PROVIDER, - nativeId: input.nativeTurnId, - strength: "strong", - }, - ordinal: input.turnInput.providerTurnOrdinal, - status: "running", - startedAt: input.startedAt, - completedAt: null, + }) { + const existing = (yield* Ref.get(activeTurns)).get(input.nativeTurnId); + if (existing !== undefined) { + return existing; + } + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CODEX_PROVIDER, + nativeTurnId: input.nativeTurnId, + }); + const context: ActiveCodexTurnContext = { + ...(input.waitForNativeStart ? { nativeStartReady: yield* Deferred.make() } : {}), + input: input.turnInput, + projectionAppThread: input.turnInput.appThread, + projectionThreadId: input.turnInput.threadId, + projectionRunId: input.turnInput.runId, + nativeTurnId: input.nativeTurnId, + providerThread: input.turnInput.providerThread, + providerTurnId, + providerTurnOrdinal: input.turnInput.providerTurnOrdinal, + providerNodeId: input.turnInput.rootNodeId, + providerNodeKind: "root_turn", + providerNodeStartedAt: input.startedAt, + itemParentNodeId: input.turnInput.rootNodeId, + rootNodeId: input.turnInput.rootNodeId, + subagent: null, + startedAt: input.startedAt, + itemPositions: new Map(), + fileChangeKindsByItem: new Map(), + }; + yield* Ref.update(limitedTurnItems, (current) => { + const next = new Map(current); + next.delete(context.providerThread.id); + return next; + }); + beginTurnTokenUsage(context); + endSessionApprovalsOnModeChange(context); + yield* Ref.update(activeTurns, (current) => { + const updated = new Map(current); + updated.set(input.nativeTurnId, context); + return updated; + }); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver: CODEX_PROVIDER, + threadId: input.turnInput.threadId, + providerTurn: { + id: providerTurnId, + providerThreadId: input.turnInput.providerThread.id, + nodeId: input.turnInput.rootNodeId, + runAttemptId: input.turnInput.attemptId, + nativeTurnRef: { + driver: CODEX_PROVIDER, + nativeId: input.nativeTurnId, + strength: "strong", }, - }); - yield* rememberRootProviderThread(input.turnInput.providerThread); - return context; + ordinal: input.turnInput.providerTurnOrdinal, + status: "running", + startedAt: input.startedAt, + completedAt: null, + }, }); + yield* rememberRootProviderThread(input.turnInput.providerThread); + return context; + }); - const findActiveTurnByNativeThreadId = (nativeThreadId: string) => - Effect.gen(function* () { + const findActiveTurnByNativeThreadId = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeThreadId: string) { const turns = Array.from((yield* Ref.get(activeTurns)).values()); return turns.find( (context) => context.providerThread.nativeThreadRef?.nativeId === nativeThreadId, ); }); - const awaitActiveTurn = ( + // Streaming updates repeatedly await this context, including retries before registration. + const awaitActiveTurn = Effect.fnUntraced(function* ( nativeTurnId: string, attemptsRemaining = 1_000, - ): Effect.Effect => - Effect.gen(function* () { - const context = (yield* Ref.get(activeTurns)).get(nativeTurnId); - if (context !== undefined || attemptsRemaining <= 0) { - return context; - } - yield* Effect.yieldNow; - return yield* awaitActiveTurn(nativeTurnId, attemptsRemaining - 1); - }); + ): Effect.fn.Return { + const context = (yield* Ref.get(activeTurns)).get(nativeTurnId); + if (context !== undefined || attemptsRemaining <= 0) { + return context; + } + yield* Effect.yieldNow; + return yield* awaitActiveTurn(nativeTurnId, attemptsRemaining - 1); + }); - const resolveItemEventContext = (nativeTurnId: string) => - Effect.gen(function* () { + const resolveItemEventContext = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeTurnId: string) { const settled = (yield* Ref.get(settledTurns)).get(nativeTurnId); if (settled !== undefined) { return { context: settled, settled: true } as const; @@ -2158,8 +2154,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return updated; }); - const turnHasRetainedBackgroundWork = (nativeTurnId: string) => - Effect.gen(function* () { + const turnHasRetainedBackgroundWork = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeTurnId: string) { const commands = (yield* Ref.get(runningCommandItemsByTurn)).get(nativeTurnId); if (commands !== undefined && commands.size > 0) { return true; @@ -2179,8 +2176,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return false; }); - const releaseSettledTurnIfIdle = (nativeTurnId: string) => - Effect.gen(function* () { + const releaseSettledTurnIfIdle = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (nativeTurnId: string) { if (yield* turnHasRetainedBackgroundWork(nativeTurnId)) { return; } @@ -2223,105 +2221,106 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( * items mid-flight (no item/completed). Emit terminal turn items before * turn.terminal so the projection never keeps a forever-running command card. */ - const terminalizeRunningCommandItems = ( + const terminalizeRunningCommandItems = Effect.fn( + "CodexAdapterV2.terminalizeRunningCommandItems", + )(function* ( context: ActiveCodexTurnContext, nativeTurnId: string, status: "interrupted" | "failed", completedAt: DateTime.Utc, - ) => - Effect.gen(function* () { - const items = (yield* Ref.get(runningCommandItemsByTurn)).get(nativeTurnId); - if (items === undefined || items.size === 0) { - return; - } - for (const tracked of items.values()) { - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: tracked.id, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: tracked.id, - }); - const { ordinal, startedAt } = yield* resolveItemPosition(context, tracked.id); - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: context.projectionThreadId, - runId: context.projectionRunId, - parentNodeId: context.itemParentNodeId, - rootNodeId: context.rootNodeId, - kind: "tool_call", - status, - countsForRun: false, - providerThreadId: context.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: codexNativeItemRef(tracked.id), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt, - }; - const turnItem: OrchestrationV2TurnItem = { - id: turnItemId, - threadId: context.projectionThreadId, - runId: context.projectionRunId, - nodeId, - providerThreadId: context.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: codexNativeItemRef(tracked.id), - parentItemId: null, - ordinal, - status, - title: null, - startedAt, - completedAt, - updatedAt: completedAt, - type: "command_execution", - input: tracked.command, - ...(tracked.aggregatedOutput === undefined - ? {} - : { output: tracked.aggregatedOutput }), - }; - yield* emitProviderEvent({ - type: "node.updated", - driver: CODEX_PROVIDER, - node, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem, - }); - } - }); - - const terminateBackgroundTerminal = Effect.fn("CodexAdapterV2.terminateBackgroundTerminal")( - function* (nativeThreadId: string, processId: string) { - const response = yield* client.raw - .request("thread/backgroundTerminals/terminate", { - threadId: nativeThreadId, - processId, - }) - .pipe( - 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. - CodexAppServerRequestError: (error) => - error.code === -32600 && error.errorMessage.startsWith("thread not found:") - ? Effect.succeed({ terminated: true }) - : Effect.fail(error), - }), - ); - const result = yield* decodeCodexBackgroundTerminalTerminateResponse(response); - if (result.terminated) return; - let cursor: string | null = null; - while (true) { - const response: unknown = yield* client.raw.request( - "thread/backgroundTerminals/list", - { + ) { + const items = (yield* Ref.get(runningCommandItemsByTurn)).get(nativeTurnId); + if (items === undefined || items.size === 0) { + return; + } + for (const tracked of items.values()) { + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: tracked.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: tracked.id, + }); + const { ordinal, startedAt } = yield* resolveItemPosition(context, tracked.id); + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: context.projectionThreadId, + runId: context.projectionRunId, + parentNodeId: context.itemParentNodeId, + rootNodeId: context.rootNodeId, + kind: "tool_call", + status, + countsForRun: false, + providerThreadId: context.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: codexNativeItemRef(tracked.id), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }; + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: context.projectionThreadId, + runId: context.projectionRunId, + nodeId, + providerThreadId: context.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: codexNativeItemRef(tracked.id), + parentItemId: null, + ordinal, + status, + title: null, + startedAt, + completedAt, + updatedAt: completedAt, + type: "command_execution", + input: tracked.command, + ...(tracked.aggregatedOutput === undefined + ? {} + : { output: tracked.aggregatedOutput }), + }; + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem, + }); + } + }); + + const terminateBackgroundTerminal = Effect.fn("CodexAdapterV2.terminateBackgroundTerminal")( + function* (nativeThreadId: string, processId: string) { + const response = yield* client.raw + .request("thread/backgroundTerminals/terminate", { + threadId: nativeThreadId, + processId, + }) + .pipe( + 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. + CodexAppServerRequestError: (error) => + error.code === -32600 && error.errorMessage.startsWith("thread not found:") + ? Effect.succeed({ terminated: true }) + : Effect.fail(error), + }), + ); + const result = yield* decodeCodexBackgroundTerminalTerminateResponse(response); + if (result.terminated) return; + let cursor: string | null = null; + while (true) { + const response: unknown = yield* client.raw.request( + "thread/backgroundTerminals/list", + { threadId: nativeThreadId, ...(cursor === null ? {} : { cursor }), }, @@ -2345,69 +2344,71 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( * item/completed when functions.wait terminates the cell. Persistent tools * can outlive the root turn and must stay running. */ - const terminalizeRunningDynamicTools = ( + const terminalizeRunningDynamicTools = Effect.fn( + "CodexAdapterV2.terminalizeRunningDynamicTools", + )(function* ( context: ActiveCodexTurnContext, nativeTurnId: string, status: "cancelled" | "interrupted" | "failed", completedAt: DateTime.Utc, includePersistent: boolean, - ) => - Effect.gen(function* () { - const items = (yield* Ref.get(runningDynamicToolsByTurn)).get(nativeTurnId); - if (items === undefined || items.size === 0) { - return; + ) { + const items = (yield* Ref.get(runningDynamicToolsByTurn)).get(nativeTurnId); + if (items === undefined || items.size === 0) { + return; + } + for (const tracked of items.values()) { + if (!includePersistent && isPersistentCodexDynamicTool(tracked)) { + continue; } - for (const tracked of items.values()) { - if (!includePersistent && isPersistentCodexDynamicTool(tracked)) { - continue; - } - const artifacts = yield* buildDynamicToolArtifacts(context, tracked); - yield* emitProviderEvent({ - type: "node.updated", - driver: CODEX_PROVIDER, - node: { - ...artifacts.node, - status, - completedAt, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: { - ...artifacts.turnItem, - status, - completedAt, - updatedAt: completedAt, - }, - }); + const artifacts = yield* buildDynamicToolArtifacts(context, tracked); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: { + ...artifacts.node, + status, + completedAt, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: { + ...artifacts.turnItem, + status, + completedAt, + updatedAt: completedAt, + }, + }); + } + yield* Ref.update(runningDynamicToolsByTurn, (current) => { + if (!current.has(nativeTurnId)) { + return current; } - yield* Ref.update(runningDynamicToolsByTurn, (current) => { - if (!current.has(nativeTurnId)) { - return current; + const remaining = new Map(current.get(nativeTurnId) ?? []); + for (const tracked of remaining.values()) { + if (includePersistent || !isPersistentCodexDynamicTool(tracked)) { + remaining.delete(tracked.id); } - const remaining = new Map(current.get(nativeTurnId) ?? []); - for (const tracked of remaining.values()) { - if (includePersistent || !isPersistentCodexDynamicTool(tracked)) { - remaining.delete(tracked.id); - } - } - const updated = new Map(current); - if (remaining.size === 0) { - updated.delete(nativeTurnId); - } else { - updated.set(nativeTurnId, remaining); - } - return updated; - }); + } + const updated = new Map(current); + if (remaining.size === 0) { + updated.delete(nativeTurnId); + } else { + updated.set(nativeTurnId, remaining); + } + return updated; }); + }); - const resolveItemPosition = ( - context: ActiveCodexTurnContext, - nativeItemId: string, - startedAt?: DateTime.Utc, - ) => - Effect.gen(function* () { + const resolveItemPosition = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + context: ActiveCodexTurnContext, + nativeItemId: string, + startedAt?: DateTime.Utc, + ) { // Read the clock first so the lookup and the insert run without yielding. const now = startedAt ?? (yield* DateTime.now); const existing = context.itemPositions.get(nativeItemId); @@ -2439,15 +2440,16 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return [next, updated]; }); - const emitSubagentTaskUpdate = (input: { - readonly subagent: CodexSubagentThreadContext; - readonly status: OrchestrationV2Subagent["status"]; - readonly result?: string | null; - readonly completedAt?: DateTime.Utc | null; - readonly startedAt?: DateTime.Utc; - readonly reopen?: boolean; - }) => - Effect.gen(function* () { + const emitSubagentTaskUpdate = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (input: { + readonly subagent: CodexSubagentThreadContext; + readonly status: OrchestrationV2Subagent["status"]; + readonly result?: string | null; + readonly completedAt?: DateTime.Utc | null; + readonly startedAt?: DateTime.Utc; + readonly reopen?: boolean; + }) { const now = yield* DateTime.now; const prior = input.subagent.task; const settled = !isOrchestrationV2WorkActive(prior.status); @@ -2510,11 +2512,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); }); - const emitSubagentProviderTurnStarted = ( - subagent: CodexSubagentThreadContext, - turn: PendingCodexSubagentTurnStarted, - ) => - Effect.gen(function* () { + const emitSubagentProviderTurnStarted = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + subagent: CodexSubagentThreadContext, + turn: PendingCodexSubagentTurnStarted, + ) { if (subagent.nativeTurnIds.has(turn.nativeTurnId)) return; subagent.nativeTurnIds.add(turn.nativeTurnId); const parentNativeThreadId = yield* getNativeThreadId( @@ -2656,12 +2659,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); }); - const rememberSubagentTurnStarted = (input: { - readonly nativeThreadId: string; - readonly nativeTurnId: string; - readonly startedAt: DateTime.Utc; - }) => - Effect.gen(function* () { + const rememberSubagentTurnStarted = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (input: { + readonly nativeThreadId: string; + readonly nativeTurnId: string; + readonly startedAt: DateTime.Utc; + }) { const subagent = (yield* Ref.get(subagentThreads)).get(input.nativeThreadId); if (subagent !== undefined) { yield* emitSubagentProviderTurnStarted(subagent, input); @@ -2729,19 +2733,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); }); - const registerSubagentThread = (input: { - readonly context: ActiveCodexTurnContext; - readonly nativeThreadId: string; - readonly nativeItemId: string; - readonly nativeToolCallId: string; - readonly prompt: string; - readonly title: string | null; - readonly model: string | null; - readonly reasoningEffort?: string | null | undefined; - readonly ordinal: number; - readonly emitInitialPrompt: boolean; - }) => - Effect.gen(function* () { + const registerSubagentThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (input: { + readonly context: ActiveCodexTurnContext; + readonly nativeThreadId: string; + readonly nativeItemId: string; + readonly nativeToolCallId: string; + readonly prompt: string; + readonly title: string | null; + readonly model: string | null; + readonly reasoningEffort?: string | null | undefined; + readonly ordinal: number; + readonly emitInitialPrompt: boolean; + }) { const registeredSubagents = yield* Ref.get(subagentThreads); if (registeredSubagents.has(input.nativeThreadId)) { return; @@ -3009,11 +3014,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }); - const registerSubagentThreads = (input: { - readonly context: ActiveCodexTurnContext; - readonly item: CodexCollabAgentToolCallItem; - }) => - Effect.gen(function* () { + const registerSubagentThreads = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (input: { + readonly context: ActiveCodexTurnContext; + readonly item: CodexCollabAgentToolCallItem; + }) { if (input.item.tool !== "spawnAgent" || input.item.receiverThreadIds.length === 0) { return; } @@ -3038,11 +3044,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }); - const registerSubagentActivity = (input: { - readonly context?: ActiveCodexTurnContext; - readonly item: CodexSubAgentActivityItem; - }) => - Effect.gen(function* () { + const registerSubagentActivity = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (input: { + readonly context?: ActiveCodexTurnContext; + readonly item: CodexSubAgentActivityItem; + }) { if (input.item.kind === "started") { const context = input.context; if (context === undefined) return; @@ -3078,8 +3085,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }); - const updateSubagentStates = (input: { readonly item: CodexCollabAgentToolCallItem }) => - Effect.gen(function* () { + const updateSubagentStates = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (input: { readonly item: CodexCollabAgentToolCallItem }) { const subagents = yield* Ref.get(subagentThreads); for (const [nativeThreadId, state] of Object.entries(input.item.agentsStates)) { const subagent = subagents.get(nativeThreadId); @@ -3107,8 +3115,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } }); - const resolvePlanId = (context: ActiveCodexTurnContext, planKey: string) => - Effect.gen(function* () { + const resolvePlanId = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (context: ActiveCodexTurnContext, planKey: string) { const existing = (yield* Ref.get(planIds)).get(planKey); if (existing !== undefined) { return existing; @@ -3126,8 +3135,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return planId; }); - const resolveCodexAttachment = (attachment: ChatAttachment) => - Effect.gen(function* () { + const resolveCodexAttachment = Effect.fn("CodexAdapterV2.resolveCodexAttachment")( + function* (attachment: ChatAttachment) { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, @@ -3150,115 +3159,115 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( type: "image" as const, url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, } satisfies CodexSchema.V2TurnStartParams__UserInput; - }); + }, + ); - const toCodexInput = ( + const toCodexInput = Effect.fn("CodexAdapterV2.toCodexInput")(function* ( turnInput: Pick< | ProviderAdapter.ProviderAdapterV2TurnInput | ProviderAdapter.ProviderAdapterV2SteerInput, "message" >, - ) => - Effect.gen(function* () { - const inputItems: Array = []; - const text = providerMessageTextWithAttachmentPaths({ - text: codexSkillMentionText(turnInput.message.text), - attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - if (text.length > 0) { - inputItems.push({ - type: "text", - text, - }); - } - const attachmentItems = yield* Effect.forEach( - turnInput.message.attachments.filter(isProviderNativeImageAttachment), - resolveCodexAttachment, - { concurrency: 1 }, - ); - inputItems.push(...attachmentItems); - if (inputItems.length === 0) { - return yield* toProtocolError("Turn requires non-empty text or attachments."); - } - return inputItems; + ) { + const inputItems: Array = []; + const text = providerMessageTextWithAttachmentPaths({ + text: codexSkillMentionText(turnInput.message.text), + attachments: turnInput.message.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); + if (text.length > 0) { + inputItems.push({ + type: "text", + text, + }); + } + const attachmentItems = yield* Effect.forEach( + turnInput.message.attachments.filter(isProviderNativeImageAttachment), + resolveCodexAttachment, + { concurrency: 1 }, + ); + inputItems.push(...attachmentItems); + if (inputItems.length === 0) { + return yield* toProtocolError("Turn requires non-empty text or attachments."); + } + return inputItems; + }); - const buildAgentMessageArtifacts = ( + // Reasoning and assistant-text coalescers build these artifacts on every streamed update. + const buildAgentMessageArtifacts = Effect.fnUntraced(function* ( context: ActiveCodexTurnContext, item: { readonly id: string; readonly text: string }, completed: boolean, - ) => - Effect.gen(function* () { - const updatedAt = yield* DateTime.now; - const completedAt = completed ? updatedAt : null; - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: item.id, - }); - const { ordinal, startedAt } = yield* resolveItemPosition(context, item.id); - const messageId = idAllocator.derive.messageFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: item.id, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: item.id, - }); - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: context.projectionThreadId, - runId: context.projectionRunId, - parentNodeId: context.itemParentNodeId, - rootNodeId: context.rootNodeId, - kind: "assistant_message", - status: completed ? "completed" : "running", - countsForRun: false, - providerThreadId: context.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: codexNativeItemRef(item.id), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt, - }; - const message: OrchestrationV2ConversationMessage = { - createdBy: "agent", - creationSource: "provider", - id: messageId, - threadId: context.projectionThreadId, - runId: context.projectionRunId, - nodeId, - role: "assistant", - text: item.text, - attachments: [], - streaming: !completed, - createdAt: startedAt, - updatedAt, - }; - const turnItem: OrchestrationV2TurnItem = { - id: turnItemId, - threadId: context.projectionThreadId, - runId: context.projectionRunId, - nodeId, - providerThreadId: context.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: codexNativeItemRef(item.id), - parentItemId: null, - ordinal, - status: completed ? "completed" : "running", - title: null, - startedAt, - completedAt, - updatedAt, - type: "assistant_message", - messageId, - text: item.text, - streaming: !completed, - }; - return { node, message, turnItem }; + ) { + const updatedAt = yield* DateTime.now; + const completedAt = completed ? updatedAt : null; + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: item.id, }); + const { ordinal, startedAt } = yield* resolveItemPosition(context, item.id); + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: item.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: item.id, + }); + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: context.projectionThreadId, + runId: context.projectionRunId, + parentNodeId: context.itemParentNodeId, + rootNodeId: context.rootNodeId, + kind: "assistant_message", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: context.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: codexNativeItemRef(item.id), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }; + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: context.projectionThreadId, + runId: context.projectionRunId, + nodeId, + role: "assistant", + text: item.text, + attachments: [], + streaming: !completed, + createdAt: startedAt, + updatedAt, + }; + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: context.projectionThreadId, + runId: context.projectionRunId, + nodeId, + providerThreadId: context.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: codexNativeItemRef(item.id), + parentItemId: null, + ordinal, + status: completed ? "completed" : "running", + title: null, + startedAt, + completedAt, + updatedAt, + type: "assistant_message", + messageId, + text: item.text, + streaming: !completed, + }; + return { node, message, turnItem }; + }); // Reuse the text coalescer so token bursts do not create one database write per token. const reasoningParts = new Map< @@ -3267,8 +3276,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( >(); const reasoningDeltas = yield* makeProviderTextDeltaCoalescer({ flushIntervalMs: CODEX_ASSISTANT_DELTA_FLUSH_INTERVAL_MS, - emit: (update) => - Effect.gen(function* () { + emit: + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (update) { const part = reasoningParts.get(update.itemId); if (part === undefined) return; const context = yield* awaitActiveTurn(update.turnId); @@ -3352,8 +3362,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const agentMessageDeltas = yield* makeProviderTextDeltaCoalescer({ flushIntervalMs: CODEX_ASSISTANT_DELTA_FLUSH_INTERVAL_MS, - emit: (update) => - Effect.gen(function* () { + emit: + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (update) { const context = yield* awaitActiveTurn(update.turnId); if (context === undefined) { return; @@ -3414,16 +3425,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), }); - const emitSubagentUserMessage = ( - context: ActiveCodexTurnContext, - item: Extract< - | CodexSchema.V2ItemStartedNotification__ThreadItem - | CodexSchema.V2ItemCompletedNotification__ThreadItem, - { type: "userMessage" } - >, - nativeStartedAt?: DateTime.Utc, - ) => - Effect.gen(function* () { + const emitSubagentUserMessage = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + context: ActiveCodexTurnContext, + item: Extract< + | CodexSchema.V2ItemStartedNotification__ThreadItem + | CodexSchema.V2ItemCompletedNotification__ThreadItem, + { type: "userMessage" } + >, + nativeStartedAt?: DateTime.Utc, + ) { if (context.subagent === null || context.providerTurnOrdinal === 1) { return false; } @@ -3466,82 +3478,83 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, turnItem: artifacts.turnItem, }); - return true; - }); - - const buildCommandExecutionArtifacts = ( - context: ActiveCodexTurnContext, - item: Extract< - | CodexSchema.V2ItemStartedNotification__ThreadItem - | CodexSchema.V2ItemCompletedNotification__ThreadItem, - { type: "commandExecution" } - >, - ) => - Effect.gen(function* () { - const updatedAt = yield* DateTime.now; - const status = codexItemStatus(item.status); - const completedAt = status.completed ? updatedAt : null; - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: item.id, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: item.id, - }); - const { ordinal, startedAt } = yield* resolveItemPosition(context, item.id); - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: context.projectionThreadId, - runId: context.projectionRunId, - parentNodeId: context.itemParentNodeId, - rootNodeId: context.rootNodeId, - kind: "tool_call", - status: status.node, - countsForRun: false, - providerThreadId: context.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: codexNativeItemRef(item.id), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt, - }; - const turnItem: OrchestrationV2TurnItem = { - id: turnItemId, - threadId: context.projectionThreadId, - runId: context.projectionRunId, - nodeId, - providerThreadId: context.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: codexNativeItemRef(item.id), - parentItemId: null, - ordinal, - status: status.turnItem, - title: null, - startedAt, - completedAt, - updatedAt, - type: "command_execution", - input: item.command, - ...(item.aggregatedOutput === null || item.aggregatedOutput === undefined - ? {} - : { output: item.aggregatedOutput }), - ...(item.exitCode === null || item.exitCode === undefined - ? {} - : { exitCode: item.exitCode }), - }; - return { node, turnItem }; + return true; }); - const buildFileChangeArtifacts = ( + const buildCommandExecutionArtifacts = Effect.fn( + "CodexAdapterV2.buildCommandExecutionArtifacts", + )(function* ( context: ActiveCodexTurnContext, item: Extract< - CodexSchema.V2ItemCompletedNotification__ThreadItem, - { type: "fileChange" } + | CodexSchema.V2ItemStartedNotification__ThreadItem + | CodexSchema.V2ItemCompletedNotification__ThreadItem, + { type: "commandExecution" } >, - ) => - Effect.gen(function* () { + ) { + const updatedAt = yield* DateTime.now; + const status = codexItemStatus(item.status); + const completedAt = status.completed ? updatedAt : null; + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: item.id, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: item.id, + }); + const { ordinal, startedAt } = yield* resolveItemPosition(context, item.id); + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: context.projectionThreadId, + runId: context.projectionRunId, + parentNodeId: context.itemParentNodeId, + rootNodeId: context.rootNodeId, + kind: "tool_call", + status: status.node, + countsForRun: false, + providerThreadId: context.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: codexNativeItemRef(item.id), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }; + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: context.projectionThreadId, + runId: context.projectionRunId, + nodeId, + providerThreadId: context.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: codexNativeItemRef(item.id), + parentItemId: null, + ordinal, + status: status.turnItem, + title: null, + startedAt, + completedAt, + updatedAt, + type: "command_execution", + input: item.command, + ...(item.aggregatedOutput === null || item.aggregatedOutput === undefined + ? {} + : { output: item.aggregatedOutput }), + ...(item.exitCode === null || item.exitCode === undefined + ? {} + : { exitCode: item.exitCode }), + }; + return { node, turnItem }; + }); + + const buildFileChangeArtifacts = Effect.fn("CodexAdapterV2.buildFileChangeArtifacts")( + function* ( + context: ActiveCodexTurnContext, + item: Extract< + CodexSchema.V2ItemCompletedNotification__ThreadItem, + { type: "fileChange" } + >, + ) { const firstChange = item.changes[0]; if (firstChange === undefined) { return null; @@ -3596,14 +3609,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( diffStr: firstChange.diff, }; return { node, turnItem }; - }); + }, + ); - const buildWebSearchArtifacts = (input: { - readonly context: ActiveCodexTurnContext; - readonly item: CodexWebSearchItem; - readonly completed: boolean; - }) => - Effect.gen(function* () { + const buildWebSearchArtifacts = Effect.fn("CodexAdapterV2.buildWebSearchArtifacts")( + function* (input: { + readonly context: ActiveCodexTurnContext; + readonly item: CodexWebSearchItem; + readonly completed: boolean; + }) { const updatedAt = yield* DateTime.now; const completedAt = input.completed ? updatedAt : null; const nodeId = idAllocator.derive.nodeFromProviderItem({ @@ -3653,13 +3667,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ...(patterns.length === 0 ? {} : { patterns: [...patterns] }), }; return { node, turnItem }; - }); + }, + ); - const buildDynamicToolArtifacts = ( - context: ActiveCodexTurnContext, - item: CodexDynamicToolItem, - ) => - Effect.gen(function* () { + const buildDynamicToolArtifacts = Effect.fn("CodexAdapterV2.buildDynamicToolArtifacts")( + function* (context: ActiveCodexTurnContext, item: CodexDynamicToolItem) { const updatedAt = yield* DateTime.now; const status = codexItemStatus(item.status); const completedAt = status.completed ? updatedAt : null; @@ -3710,36 +3722,36 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ...projection, }; return { node, turnItem }; - }); + }, + ); /** * The item stays running until then, which keeps the run's event * ingestion open past turn/completed; it runs off the notification * reader so a slow server cannot stall other events. */ - const captureMcpApp = ( + const captureMcpApp = Effect.fn("CodexAdapterV2.captureMcpApp")(function* ( context: ActiveCodexTurnContext, nativeTurnId: string, item: Extract, resourceUri: string, - ) => - Effect.gen(function* () { - // Registered before it can run, so a turn ending right away sees it. - const started = yield* Deferred.make(); - const fiber = yield* Deferred.await(started).pipe( - Effect.andThen(captureMcpAppItem(context, nativeTurnId, item, resourceUri)), - Effect.forkIn(scope, { startImmediately: true }), + ) { + // Registered before it can run, so a turn ending right away sees it. + const started = yield* Deferred.make(); + const fiber = yield* Deferred.await(started).pipe( + Effect.andThen(captureMcpAppItem(context, nativeTurnId, item, resourceUri)), + Effect.forkIn(scope, { startImmediately: true }), + ); + yield* Ref.update(pendingMcpAppCaptures, (current) => { + const updated = new Map(current); + updated.set( + nativeTurnId, + new Map(current.get(nativeTurnId) ?? []).set(item.id, { item, fiber }), ); - yield* Ref.update(pendingMcpAppCaptures, (current) => { - const updated = new Map(current); - updated.set( - nativeTurnId, - new Map(current.get(nativeTurnId) ?? []).set(item.id, { item, fiber }), - ); - return updated; - }); - yield* Deferred.succeed(started, undefined); + return updated; }); + yield* Deferred.succeed(started, undefined); + }); const captureMcpAppItem = ( context: ActiveCodexTurnContext, @@ -3830,8 +3842,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ); - const terminalizeMcpAppCaptures = (context: ActiveCodexTurnContext, nativeTurnId: string) => - Effect.gen(function* () { + const terminalizeMcpAppCaptures = Effect.fn("CodexAdapterV2.terminalizeMcpAppCaptures")( + function* (context: ActiveCodexTurnContext, nativeTurnId: string) { const captures = (yield* Ref.get(pendingMcpAppCaptures)).get(nativeTurnId); if (captures === undefined) return; for (const { item, fiber } of captures.values()) { @@ -3849,84 +3861,86 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( turnItem: artifacts.turnItem, }); } - }); + }, + ); - const buildProposedPlanArtifacts = (input: { + // The plan coalescer builds these artifacts on every streamed update. + const buildProposedPlanArtifacts = Effect.fnUntraced(function* (input: { readonly context: ActiveCodexTurnContext; readonly nativeItemId: string; readonly status: OrchestrationV2PlanArtifact["status"]; readonly markdown: string; readonly completed?: boolean; - }) => - Effect.gen(function* () { - const updatedAt = yield* DateTime.now; - const completedAt = input.completed === true ? updatedAt : null; - const planId = yield* resolvePlanId(input.context, input.nativeItemId); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: input.nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: input.nativeItemId, - }); - const { ordinal, startedAt } = yield* resolveItemPosition( - input.context, - input.nativeItemId, - ); - const plan: OrchestrationV2PlanArtifact = { - id: planId, - threadId: input.context.projectionThreadId, - runId: input.context.projectionRunId, - nodeId, - kind: "proposed_plan", - status: input.status, - markdown: input.markdown, - }; - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: input.context.projectionThreadId, - runId: input.context.projectionRunId, - parentNodeId: input.context.itemParentNodeId, - rootNodeId: input.context.rootNodeId, - kind: "plan", - status: input.completed === true ? "completed" : "running", - countsForRun: false, - providerThreadId: input.context.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), - runtimeRequestId: null, - checkpointScopeId: null, - startedAt, - completedAt, - }; - const turnItem: OrchestrationV2TurnItem = { - id: turnItemId, - threadId: input.context.projectionThreadId, - runId: input.context.projectionRunId, - nodeId, - providerThreadId: input.context.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), - parentItemId: null, - ordinal, - status: input.completed === true ? "completed" : "running", - title: null, - startedAt, - completedAt, - updatedAt, - type: "proposed_plan", - planId, - markdown: input.markdown, - streaming: input.completed !== true, - }; - return { node, plan, turnItem }; + }) { + const updatedAt = yield* DateTime.now; + const completedAt = input.completed === true ? updatedAt : null; + const planId = yield* resolvePlanId(input.context, input.nativeItemId); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: input.nativeItemId, }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: input.nativeItemId, + }); + const { ordinal, startedAt } = yield* resolveItemPosition( + input.context, + input.nativeItemId, + ); + const plan: OrchestrationV2PlanArtifact = { + id: planId, + threadId: input.context.projectionThreadId, + runId: input.context.projectionRunId, + nodeId, + kind: "proposed_plan", + status: input.status, + markdown: input.markdown, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: input.context.projectionThreadId, + runId: input.context.projectionRunId, + parentNodeId: input.context.itemParentNodeId, + rootNodeId: input.context.rootNodeId, + kind: "plan", + status: input.completed === true ? "completed" : "running", + countsForRun: false, + providerThreadId: input.context.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: codexNativeItemRef(input.nativeItemId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }; + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: input.context.projectionThreadId, + runId: input.context.projectionRunId, + nodeId, + providerThreadId: input.context.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: codexNativeItemRef(input.nativeItemId), + parentItemId: null, + ordinal, + status: input.completed === true ? "completed" : "running", + title: null, + startedAt, + completedAt, + updatedAt, + type: "proposed_plan", + planId, + markdown: input.markdown, + streaming: input.completed !== true, + }; + return { node, plan, turnItem }; + }); const planDeltas = yield* makeProviderTextDeltaCoalescer({ flushIntervalMs: CODEX_PLAN_DELTA_FLUSH_INTERVAL_MS, - emit: (update) => - Effect.gen(function* () { + emit: + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (update) { const resolved = yield* resolveItemEventContext(update.turnId); if (resolved === undefined) return; const interrupted = (yield* Ref.get(terminalizedNonCompletedNativeTurns)).has( @@ -3955,18 +3969,18 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, turnItem: artifacts.turnItem, }); - }).pipe(Effect.orDie), + }, Effect.orDie), }); - const buildTodoListArtifacts = (input: { - readonly context: ActiveCodexTurnContext; - readonly nativeItemId: string; - readonly status: OrchestrationV2PlanArtifact["status"]; - readonly steps: ReadonlyArray; - readonly explanation?: string; - readonly completed?: boolean; - }) => - Effect.gen(function* () { + const buildTodoListArtifacts = Effect.fn("CodexAdapterV2.buildTodoListArtifacts")( + function* (input: { + readonly context: ActiveCodexTurnContext; + readonly nativeItemId: string; + readonly status: OrchestrationV2PlanArtifact["status"]; + readonly steps: ReadonlyArray; + readonly explanation?: string; + readonly completed?: boolean; + }) { const updatedAt = yield* DateTime.now; const completedAt = input.completed === true ? updatedAt : null; const planId = yield* resolvePlanId(input.context, input.nativeItemId); @@ -4030,9 +4044,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ...(input.explanation === undefined ? {} : { explanation: input.explanation }), }; return { node, plan, turnItem }; - }); + }, + ); - const buildApprovalRequestArtifacts = (input: { + const buildApprovalRequestArtifacts = Effect.fn( + "CodexAdapterV2.buildApprovalRequestArtifacts", + )(function* (input: { readonly context: ActiveCodexTurnContext; readonly nativeItemId: string; readonly nativeRequestId: string; @@ -4040,190 +4057,190 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly prompt?: string | null; readonly appName?: string; readonly options?: ReadonlyArray; - }) => - Effect.gen(function* () { - const createdAt = yield* DateTime.now; - const { owner, subagent } = approvalOwnerCodexTurn(input.context); - const parentNodeId = - subagent?.subagentNodeId ?? - idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: input.nativeItemId, - }); - const ordinal = yield* resolveItemOrdinal( - owner, - `${input.nativeItemId}:approval:${input.nativeRequestId}`, - ); - const requestId = yield* idAllocator.allocate.runtimeRequest({ + }) { + const createdAt = yield* DateTime.now; + const { owner, subagent } = approvalOwnerCodexTurn(input.context); + const parentNodeId = + subagent?.subagentNodeId ?? + idAllocator.derive.nodeFromProviderItem({ driver: CODEX_PROVIDER, - providerTurnId: owner.providerTurnId, - nativeRequestId: input.nativeRequestId, + nativeItemId: input.nativeItemId, }); - const nodeId = idAllocator.derive.approvalNode({ requestId }); - const providerSessionId = owner.input.providerThread.providerSessionId; - if (providerSessionId === null) { - return yield* toProtocolError( - `Provider thread ${owner.providerThread.id} is missing a provider session id.`, - ); - } - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: owner.projectionThreadId, - runId: owner.projectionRunId, - parentNodeId, - rootNodeId: owner.rootNodeId, - kind: "approval_request", - status: "waiting", - countsForRun: false, - providerThreadId: owner.providerThread.id, - providerTurnId: owner.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: createdAt, - completedAt: null, - }; - const request: OrchestrationV2RuntimeRequest = { - id: requestId, - nodeId, - providerTurnId: owner.providerTurnId, - nativeRequestRef: { - driver: CODEX_PROVIDER, - nativeId: input.nativeRequestId, - strength: "strong", - }, - kind: input.requestKind, - status: "pending", - responseCapability: { - type: "live", - providerSessionId, - }, - createdAt, - resolvedAt: null, - }; - const turnItem: OrchestrationV2TurnItem = { - id: idAllocator.derive.approvalTurnItem({ requestId }), - threadId: owner.projectionThreadId, - runId: owner.projectionRunId, - nodeId, - providerThreadId: owner.providerThread.id, - providerTurnId: owner.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), - parentItemId: null, - ordinal, - status: "waiting", - title: null, - startedAt: createdAt, - completedAt: null, - updatedAt: createdAt, - type: "approval_request", - requestId, - requestKind: input.requestKind, - ...(input.prompt === null || input.prompt === undefined - ? {} - : { prompt: input.prompt }), - ...(input.appName === undefined ? {} : { appName: input.appName }), - ...(input.options === undefined ? {} : { options: input.options }), - }; - return { node, request, turnItem }; + const ordinal = yield* resolveItemOrdinal( + owner, + `${input.nativeItemId}:approval:${input.nativeRequestId}`, + ); + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: CODEX_PROVIDER, + providerTurnId: owner.providerTurnId, + nativeRequestId: input.nativeRequestId, }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const providerSessionId = owner.input.providerThread.providerSessionId; + if (providerSessionId === null) { + return yield* toProtocolError( + `Provider thread ${owner.providerThread.id} is missing a provider session id.`, + ); + } + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: owner.projectionThreadId, + runId: owner.projectionRunId, + parentNodeId, + rootNodeId: owner.rootNodeId, + kind: "approval_request", + status: "waiting", + countsForRun: false, + providerThreadId: owner.providerThread.id, + providerTurnId: owner.providerTurnId, + nativeItemRef: codexNativeItemRef(input.nativeItemId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: createdAt, + completedAt: null, + }; + const request: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: owner.providerTurnId, + nativeRequestRef: { + driver: CODEX_PROVIDER, + nativeId: input.nativeRequestId, + strength: "strong", + }, + kind: input.requestKind, + status: "pending", + responseCapability: { + type: "live", + providerSessionId, + }, + createdAt, + resolvedAt: null, + }; + const turnItem: OrchestrationV2TurnItem = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId: owner.projectionThreadId, + runId: owner.projectionRunId, + nodeId, + providerThreadId: owner.providerThread.id, + providerTurnId: owner.providerTurnId, + nativeItemRef: codexNativeItemRef(input.nativeItemId), + parentItemId: null, + ordinal, + status: "waiting", + title: null, + startedAt: createdAt, + completedAt: null, + updatedAt: createdAt, + type: "approval_request", + requestId, + requestKind: input.requestKind, + ...(input.prompt === null || input.prompt === undefined + ? {} + : { prompt: input.prompt }), + ...(input.appName === undefined ? {} : { appName: input.appName }), + ...(input.options === undefined ? {} : { options: input.options }), + }; + return { node, request, turnItem }; + }); - const buildUserInputRequestArtifacts = (input: { + const buildUserInputRequestArtifacts = Effect.fn( + "CodexAdapterV2.buildUserInputRequestArtifacts", + )(function* (input: { readonly context: ActiveCodexTurnContext; readonly nativeItemId: string; readonly nativeRequestId: string; readonly questions: ReadonlyArray; readonly responseMode?: "message"; - }) => - Effect.gen(function* () { - const createdAt = yield* DateTime.now; - const requestId = yield* idAllocator.allocate.runtimeRequest({ - driver: CODEX_PROVIDER, - providerTurnId: input.context.providerTurnId, - nativeRequestId: input.nativeRequestId, - }); - const providerSessionId = input.context.input.providerThread.providerSessionId; - if (providerSessionId === null) { - return yield* toProtocolError( - `Provider thread ${input.context.providerThread.id} is missing a provider session id.`, - ); - } - const questions = input.questions.map((question, index) => ({ - id: nonEmptyText(question.id, `question-${index + 1}`), - header: nonEmptyText(question.header, "Question"), - question: nonEmptyText(question.question, "Choose an answer."), - options: - question.options?.map((option, optionIndex) => ({ - label: nonEmptyText(option.label, `Option ${optionIndex + 1}`), - description: nonEmptyText(option.description, option.label), - })) ?? [], - })); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: input.nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: input.nativeItemId, - }); - const ordinal = yield* resolveItemOrdinal(input.context, input.nativeItemId); - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: input.context.projectionThreadId, - runId: input.context.projectionRunId, - parentNodeId: input.context.itemParentNodeId, - rootNodeId: input.context.rootNodeId, - kind: "user_input_request", - status: "waiting", - countsForRun: false, - providerThreadId: input.context.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: createdAt, - completedAt: null, - }; - const request: OrchestrationV2RuntimeRequest = { - id: requestId, - nodeId, - providerTurnId: input.context.providerTurnId, - nativeRequestRef: { - driver: CODEX_PROVIDER, - nativeId: input.nativeRequestId, - strength: "strong", - }, - kind: "user_input", - status: "pending", - responseCapability: - input.responseMode === "message" - ? { type: "message" } - : { type: "live", providerSessionId }, - createdAt, - resolvedAt: null, - }; - const turnItem: OrchestrationV2TurnItem = { - id: turnItemId, - threadId: input.context.projectionThreadId, - runId: input.context.projectionRunId, - nodeId, - providerThreadId: input.context.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), - parentItemId: null, - ordinal, - status: "waiting", - title: null, - startedAt: createdAt, - completedAt: null, - updatedAt: createdAt, - type: "user_input_request", - requestId, - questions, - ...(input.responseMode === undefined ? {} : { responseMode: input.responseMode }), - }; - return { node, request, turnItem }; + }) { + const createdAt = yield* DateTime.now; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: CODEX_PROVIDER, + providerTurnId: input.context.providerTurnId, + nativeRequestId: input.nativeRequestId, + }); + const providerSessionId = input.context.input.providerThread.providerSessionId; + if (providerSessionId === null) { + return yield* toProtocolError( + `Provider thread ${input.context.providerThread.id} is missing a provider session id.`, + ); + } + const questions = input.questions.map((question, index) => ({ + id: nonEmptyText(question.id, `question-${index + 1}`), + header: nonEmptyText(question.header, "Question"), + question: nonEmptyText(question.question, "Choose an answer."), + options: + question.options?.map((option, optionIndex) => ({ + label: nonEmptyText(option.label, `Option ${optionIndex + 1}`), + description: nonEmptyText(option.description, option.label), + })) ?? [], + })); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: input.nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: input.nativeItemId, }); + const ordinal = yield* resolveItemOrdinal(input.context, input.nativeItemId); + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: input.context.projectionThreadId, + runId: input.context.projectionRunId, + parentNodeId: input.context.itemParentNodeId, + rootNodeId: input.context.rootNodeId, + kind: "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: input.context.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: codexNativeItemRef(input.nativeItemId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: createdAt, + completedAt: null, + }; + const request: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: input.context.providerTurnId, + nativeRequestRef: { + driver: CODEX_PROVIDER, + nativeId: input.nativeRequestId, + strength: "strong", + }, + kind: "user_input", + status: "pending", + responseCapability: + input.responseMode === "message" + ? { type: "message" } + : { type: "live", providerSessionId }, + createdAt, + resolvedAt: null, + }; + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: input.context.projectionThreadId, + runId: input.context.projectionRunId, + nodeId, + providerThreadId: input.context.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: codexNativeItemRef(input.nativeItemId), + parentItemId: null, + ordinal, + status: "waiting", + title: null, + startedAt: createdAt, + completedAt: null, + updatedAt: createdAt, + type: "user_input_request", + requestId, + questions, + ...(input.responseMode === undefined ? {} : { responseMode: input.responseMode }), + }; + return { node, request, turnItem }; + }); yield* client.handleServerNotification("item/agentMessage/delta", (payload) => Effect.gen(function* () { @@ -6214,11 +6231,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); }); - const startNativeTurn = ( - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - codexInput: ReadonlyArray, - ) => - Effect.gen(function* () { + const startNativeTurn = Effect.fn("CodexAdapterV2.startNativeTurn")( + function* ( + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + codexInput: ReadonlyArray, + ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); const turnStartParams = yield* buildCodexTurnStartParams({ @@ -6247,32 +6264,35 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const started = yield* client.request("turn/start", turnStartParams); yield* registerRootTurn({ turnInput, - nativeTurnId: started.turn.id, - startedAt: codexTimestamp(started.turn.startedAt), - waitForNativeStart: started.turn.startedAt === null, - }); - }).pipe( - Effect.ensuring( - Effect.flatMap(getNativeThreadId(turnInput.providerThread), (threadId) => - Ref.update(pendingRootTurns, (current) => { - const updated = new Map(current); - updated.delete(threadId); - return updated; - }), - ).pipe(Effect.ignore), + nativeTurnId: started.turn.id, + startedAt: codexTimestamp(started.turn.startedAt), + waitForNativeStart: started.turn.startedAt === null, + }); + }, + (effect, turnInput) => + effect.pipe( + Effect.ensuring( + Effect.flatMap(getNativeThreadId(turnInput.providerThread), (threadId) => + Ref.update(pendingRootTurns, (current) => { + const updated = new Map(current); + updated.delete(threadId); + return updated; + }), + ).pipe(Effect.ignore), + ), ), - ); + ); /** * Setting or resuming a goal starts the first goal turn with this run's * turn configuration (approvals, sandbox, model, plan mode), since a * turn Codex starts on its own reuses the last turn's settings. */ - const runGoalCommand = ( - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - command: CodexGoalCommand, - ) => - Effect.gen(function* () { + const runGoalCommand = Effect.fn("CodexAdapterV2.runGoalCommand")( + function* ( + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + command: CodexGoalCommand, + ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); rootProviderThreads.set(threadId, turnInput.providerThread); const readGoal = client @@ -6363,18 +6383,21 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ), ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: CODEX_PROVIDER, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: CODEX_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), ), - ); + ); const mcpAppsError = (detail: string) => (cause: unknown) => new ProviderAdapter.ProviderAdapterProtocolError({ @@ -6383,8 +6406,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( payload: cause, }); const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = { - listTools: (toolsInput) => - Effect.gen(function* () { + listTools: Effect.fn("CodexAdapterV2.listTools")( + function* (toolsInput) { const threadId = yield* getNativeThreadId(toolsInput.providerThread); const response = yield* ensureInitialized.pipe( Effect.andThen( @@ -6397,9 +6420,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); const server = response.data.find((entry) => entry.name === toolsInput.server); return Object.values(server?.tools ?? {}); - }).pipe(Effect.mapError(mcpAppsError("Failed to list Codex MCP tools."))), - callTool: (callInput) => - Effect.gen(function* () { + }, + Effect.mapError(mcpAppsError("Failed to list Codex MCP tools.")), + ), + callTool: Effect.fn("CodexAdapterV2.callTool")( + function* (callInput) { const threadId = yield* getNativeThreadId(callInput.providerThread); const response = yield* ensureInitialized.pipe( Effect.andThen( @@ -6419,9 +6444,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ...(response.isError === true ? { isError: true } : {}), ...(response._meta === undefined ? {} : { _meta: response._meta }), }; - }).pipe(Effect.mapError(mcpAppsError("Codex MCP tool call failed."))), - readResource: (readInput) => - Effect.gen(function* () { + }, + Effect.mapError(mcpAppsError("Codex MCP tool call failed.")), + ), + readResource: Effect.fn("CodexAdapterV2.readResource")( + function* (readInput) { const threadId = yield* getNativeThreadId(readInput.providerThread); const response = yield* ensureInitialized.pipe( Effect.andThen( @@ -6433,7 +6460,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ); return { contents: response.contents }; - }).pipe(Effect.mapError(mcpAppsError("Codex MCP resource read failed."))), + }, + Effect.mapError(mcpAppsError("Codex MCP resource read failed.")), + ), }; const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { @@ -6468,27 +6497,28 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } return false; }), - hasPendingBackgroundWorkForThread: (providerThread) => - Effect.gen(function* () { - const contexts = [ - ...(yield* Ref.get(activeTurns)).values(), - ...(yield* Ref.get(settledTurns)).values(), - ]; - const roots = contexts.filter( - (context) => context.providerThread.id === providerThread.id, - ); - for (const context of contexts) { - if (!roots.some((root) => context === root || isDescendantCodexTurn(context, root))) - continue; - if (yield* turnHasRetainedBackgroundWork(context.nativeTurnId)) return true; - if ( - context.subagent !== null && - (yield* Ref.get(activeTurns)).has(context.nativeTurnId) - ) - return true; - } - return false; - }), + hasPendingBackgroundWorkForThread: Effect.fn( + "CodexAdapterV2.hasPendingBackgroundWorkForThread", + )(function* (providerThread) { + const contexts = [ + ...(yield* Ref.get(activeTurns)).values(), + ...(yield* Ref.get(settledTurns)).values(), + ]; + const roots = contexts.filter( + (context) => context.providerThread.id === providerThread.id, + ); + for (const context of contexts) { + if (!roots.some((root) => context === root || isDescendantCodexTurn(context, root))) + continue; + if (yield* turnHasRetainedBackgroundWork(context.nativeTurnId)) return true; + if ( + context.subagent !== null && + (yield* Ref.get(activeTurns)).has(context.nativeTurnId) + ) + return true; + } + return false; + }), ensureThread: (threadInput) => ensureInitialized.pipe( Effect.andThen(mcpSessions.read(threadInput.threadId)), @@ -6521,8 +6551,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ), ), - resumeThread: (threadInput) => - Effect.gen(function* () { + resumeThread: Effect.fn("CodexAdapterV2.resumeThread")( + function* (threadInput) { const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread); // excludeTurns is not in the generated request schema yet. const resume = client.raw.request("thread/resume", { @@ -6574,19 +6604,22 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( nativeConversationHeadRef: threadInput.providerThread.nativeConversationHeadRef, updatedAt: codexTimestamp(response.thread.updatedAt), } satisfies OrchestrationV2ProviderThread; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver: CODEX_PROVIDER, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause: normalizeCodexCause(cause), - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause: normalizeCodexCause(cause), + }), + ), ), - ), - compactThread: (turnInput) => - Effect.gen(function* () { + ), + compactThread: Effect.fn("CodexAdapterV2.compactThread")( + function* (turnInput) { const threadId = yield* getNativeThreadId(turnInput.providerThread); yield* Ref.update(pendingRootTurns, (current) => new Map(current).set(threadId, turnInput), @@ -6602,20 +6635,23 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ), ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: CODEX_PROVIDER, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: CODEX_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), ), - ), - injectHistory: (input) => - Effect.gen(function* () { + ), + injectHistory: Effect.fn("CodexAdapterV2.injectHistory")( + function* (input) { const threadId = yield* getNativeThreadId(input.providerThread); return yield* client .request("thread/inject_items", { @@ -6632,16 +6668,16 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( error.code === -32601 ? Effect.succeed(false) : Effect.fail(error), }), ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CODEX_PROVIDER, - detail: "Failed to inject historical context", - cause, - }), - ), + }, + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail: "Failed to inject historical context", + cause, + }), ), + ), startTurn: (turnInput) => { const goalCommand = turnInput.message.attachments.length === 0 && @@ -6669,8 +6705,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ); }, - steerTurn: (turnInput) => - Effect.gen(function* () { + steerTurn: Effect.fn("CodexAdapterV2.steerTurn")( + function* (turnInput) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const latestTurnId = latestGoalTurnId(turnInput.providerTurnId); const held = heldGoalTurn(turnInput.providerThread, latestTurnId); @@ -6693,37 +6729,43 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( input: codexInput, threadId, }); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterSteerRunError({ - driver: CODEX_PROVIDER, - providerThreadId: turnInput.providerThread.id, - providerTurnId: turnInput.providerTurnId, - cause, - }), + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver: CODEX_PROVIDER, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), + ), ), - ), + ), // Dropping this connection's subscription lets the shared app-server // shut the native thread (and its MCP servers) down once it is idle. - unloadThread: (unloadInput) => - Effect.gen(function* () { + unloadThread: Effect.fn("CodexAdapterV2.unloadThread")( + function* (unloadInput) { const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread); sessionApprovals.delete(nativeThreadId); yield* client.request("thread/unsubscribe", { threadId: nativeThreadId }); - }).pipe( - Effect.mapError((cause) => - cause._tag === "ProviderAdapterProtocolError" - ? cause - : new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CODEX_PROVIDER, - detail: `Failed to unload Codex thread for provider thread ${unloadInput.providerThread.id}`, - cause: normalizeCodexCause(cause), - }), + }, + (effect, unloadInput) => + effect.pipe( + Effect.mapError((cause) => + cause._tag === "ProviderAdapterProtocolError" + ? cause + : new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail: `Failed to unload Codex thread for provider thread ${unloadInput.providerThread.id}`, + cause: normalizeCodexCause(cause), + }), + ), ), - ), - interruptTurn: (requestedInput) => - Effect.gen(function* () { + ), + interruptTurn: Effect.fn("CodexAdapterV2.interruptTurn")( + function* (requestedInput) { const { turnInput, goalTurns } = yield* resolveGoalStopTarget(requestedInput); const [activeTurnContexts, settledTurnContexts] = yield* turnTerminalizationPermit.withPermits(1)( @@ -6977,8 +7019,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } const containedTerminalKeys = new Set(); const attemptedTerminalKeys = new Set(); - const collectTrackedTerminals = (targets: typeof interruptTargets) => - Effect.gen(function* () { + const collectTrackedTerminals = Effect.fn("CodexAdapterV2.collectTrackedTerminals")( + function* (targets: typeof interruptTargets) { const trackedTerminals = new Map< string, { readonly nativeThreadId: string; readonly processId: string } @@ -6999,32 +7041,32 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } } return trackedTerminals; - }); - const terminateTrackedTerminals = (targets: typeof interruptTargets) => - Effect.gen(function* () { - const trackedTerminals = yield* collectTrackedTerminals(targets); - const pendingTerminals = Array.from(trackedTerminals.entries()) - .filter(([key]) => !containedTerminalKeys.has(key)) - .sort( - ([leftKey], [rightKey]) => - Number(attemptedTerminalKeys.has(leftKey)) - - Number(attemptedTerminalKeys.has(rightKey)), - ); - return yield* Effect.forEach( - pendingTerminals, - ([key, { nativeThreadId, processId }]) => - Effect.gen(function* () { - attemptedTerminalKeys.add(key); - yield* terminateBackgroundTerminal(nativeThreadId, processId); - containedTerminalKeys.add(key); - }).pipe( - Effect.as({ success: true as const }), - Effect.catch((error) => - Effect.succeed({ success: false as const, error }), - ), - ), + }, + ); + const terminateTrackedTerminals = Effect.fn( + "CodexAdapterV2.terminateTrackedTerminals", + )(function* (targets: typeof interruptTargets) { + const trackedTerminals = yield* collectTrackedTerminals(targets); + const pendingTerminals = Array.from(trackedTerminals.entries()) + .filter(([key]) => !containedTerminalKeys.has(key)) + .sort( + ([leftKey], [rightKey]) => + Number(attemptedTerminalKeys.has(leftKey)) - + Number(attemptedTerminalKeys.has(rightKey)), ); - }); + return yield* Effect.forEach( + pendingTerminals, + ([key, { nativeThreadId, processId }]) => + Effect.gen(function* () { + attemptedTerminalKeys.add(key); + yield* terminateBackgroundTerminal(nativeThreadId, processId); + containedTerminalKeys.add(key); + }).pipe( + Effect.as({ success: true as const }), + Effect.catch((error) => Effect.succeed({ success: false as const, error })), + ), + ); + }); const [, completed] = yield* Effect.all( [ @@ -7094,19 +7136,22 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.onError(() => finalizeRemainingInterruptLineage), Effect.ensuring(cleanupInterruptState), ); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: CODEX_PROVIDER, - providerThreadId: requestedInput.providerThread.id, - providerTurnId: requestedInput.providerTurnId, - cause, - }), + }, + (effect, requestedInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: CODEX_PROVIDER, + providerThreadId: requestedInput.providerThread.id, + providerTurnId: requestedInput.providerTurnId, + cause, + }), + ), ), - ), - respondToRuntimeRequest: (requestInput) => - Effect.gen(function* () { + ), + respondToRuntimeRequest: Effect.fn("CodexAdapterV2.respondToRuntimeRequest")( + function* (requestInput) { const pending = (yield* Ref.get(pendingRuntimeRequests)).get( String(requestInput.requestId), ); @@ -7142,19 +7187,22 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); } yield* Deferred.succeed(pending.decision, requestInput.decision); - }).pipe( - Effect.mapError((cause) => - isProviderAdapterRuntimeRequestResponseError(cause) - ? cause - : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver: CODEX_PROVIDER, - requestId: requestInput.requestId, - cause, - }), + }, + (effect, requestInput) => + effect.pipe( + Effect.mapError((cause) => + isProviderAdapterRuntimeRequestResponseError(cause) + ? cause + : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: CODEX_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), ), - ), - uploadFeedback: (feedbackInput) => - Effect.gen(function* () { + ), + uploadFeedback: Effect.fn("CodexAdapterV2.uploadFeedback")( + function* (feedbackInput) { const threadId = yield* getNativeThreadId(feedbackInput.providerThread); const response = yield* ensureInitialized.pipe( Effect.andThen( @@ -7167,19 +7215,19 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ); return { feedbackId: response.threadId }; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CODEX_PROVIDER, - detail: "Failed to upload Codex thread feedback.", - payload: cause, - }), - ), + }, + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail: "Failed to upload Codex thread feedback.", + payload: cause, + }), ), + ), mcpApps, - readThreadSnapshot: (threadInput) => - Effect.gen(function* () { + readThreadSnapshot: Effect.fn("CodexAdapterV2.readThreadSnapshot")( + function* (threadInput) { const threadId = yield* getNativeThreadId(threadInput.providerThread); const response = yield* ensureInitialized.pipe( Effect.andThen(client.request("thread/read", { threadId, includeTurns: true })), @@ -7200,18 +7248,21 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( runtimeRequests: [], providerPayload: response.thread, }; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver: CODEX_PROVIDER, - providerThreadId: threadInput.providerThread.id, - cause, - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: CODEX_PROVIDER, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), ), - ), - rollbackThread: (threadInput) => - Effect.gen(function* () { + ), + rollbackThread: Effect.fn("CodexAdapterV2.rollbackThread")( + function* (threadInput) { const threadId = yield* getNativeThreadId(threadInput.providerThread); const numTurns = yield* resolveCodexRollbackTurnCount(threadInput); const nativeConversationHeadRef = @@ -7275,18 +7326,21 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( runtimeRequests: [], providerPayload: response.thread, }; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: CODEX_PROVIDER, - providerThreadId: threadInput.providerThread.id, - cause: normalizeCodexCause(cause), - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: CODEX_PROVIDER, + providerThreadId: threadInput.providerThread.id, + cause: normalizeCodexCause(cause), + }), + ), ), - ), - forkThread: (threadInput) => - Effect.gen(function* () { + ), + forkThread: Effect.fn("CodexAdapterV2.forkThread")( + function* (threadInput) { const threadId = yield* getNativeThreadId(threadInput.sourceProviderThread); const boundary = yield* resolveCodexForkBoundary(threadInput); const response = yield* ensureInitialized.pipe( @@ -7356,27 +7410,33 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( : { providerTurnId: threadInput.providerTurnId }), }, }); - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterForkThreadError({ - driver: CODEX_PROVIDER, - providerThreadId: threadInput.sourceProviderThread.id, - cause: normalizeCodexCause(cause), - }), + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: CODEX_PROVIDER, + providerThreadId: threadInput.sourceProviderThread.id, + cause: normalizeCodexCause(cause), + }), + ), ), - ), + ), }; return runtime; - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CODEX_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), ), - ), + ), }); }); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index ea1c8f7448cc..c97aa949aab8 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -261,8 +261,8 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < ), ); - const runSignIn = (flow: AuthFlow, stopSessions: Effect.Effect) => - Effect.gen(function* () { + const runSignIn = Effect.fn("AntigravityAuth.runSignIn")( + function* (flow: AuthFlow, stopSessions: Effect.Effect) { yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses)); const runtime = yield* options.makeRuntime({ onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url), @@ -281,16 +281,19 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < }), ); yield* options.onAuthenticated(started, runtime); - }).pipe( - Effect.scoped, - Effect.timeoutOrElse({ - duration: AUTH_TIMEOUT_MS, - orElse: () => - Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), - }), - Effect.exit, - Effect.flatMap((result) => finishFlow(flow, result)), - ); + }, + (effect, flow, stopSessions) => + effect.pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: AUTH_TIMEOUT_MS, + orElse: () => + Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), + }), + Effect.exit, + Effect.flatMap((result) => finishFlow(flow, result)), + ), + ); const stopFlow = (flow: AuthFlow, phase: "cancelled" | "failed", message: string) => Effect.uninterruptible( @@ -322,18 +325,21 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < }), ); - const requireFlow = (ownerSessionId: string, flowId: string, name: string) => - Effect.gen(function* () { - const flow = activeFlow; - if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) { - return yield* setupError(name, "This sign-in is no longer active in this client."); - } - const now = yield* Clock.currentTimeMillis; - if (now >= flow.expiresAtMillis) { - return yield* setupError(name, "Google sign-in expired. Start sign-in again."); - } - return flow; - }); + const requireFlow = Effect.fn("AntigravityAuth.requireFlow")(function* ( + ownerSessionId: string, + flowId: string, + name: string, + ) { + const flow = activeFlow; + if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) { + return yield* setupError(name, "This sign-in is no longer active in this client."); + } + const now = yield* Clock.currentTimeMillis; + if (now >= flow.expiresAtMillis) { + return yield* setupError(name, "Google sign-in expired. Start sign-in again."); + } + return flow; + }); const controller: ProviderAuthController = { start: (ownerSessionId, stopSessions = Effect.void) => diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index a30e58f177c3..89a13d81c8ac 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -684,11 +684,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -export const makePendingClaudeProvider = ( - claudeSettings: ClaudeSettings, - modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, -): Effect.Effect => - Effect.gen(function* () { +export const makePendingClaudeProvider = Effect.fn("ClaudeProvider.makePendingClaudeProvider")( + function* ( + claudeSettings: ClaudeSettings, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, + ): Effect.fn.Return { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( modelCatalog.models.map((entry) => entry.model), @@ -725,6 +725,7 @@ export const makePendingClaudeProvider = ( message: "Claude provider status has not been checked in this session yet.", }, }); - }); + }, +); export { probeClaudeCapabilities }; diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 6a393ace4c60..a9bf5dbac749 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -109,49 +109,48 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* }) { const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService); const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); - const track = ( + const track = Effect.fn("CodexChatGptAuth.track")(function* ( event: "auth" | "transfer", properties: Readonly>, task: Effect.Effect, expiresAt?: number, - ) => - Effect.gen(function* () { - if (Option.isNone(analytics)) return yield* task; - const record = (name: string, properties: Readonly>) => - analytics.value.record(name, properties).pipe(Effect.ignoreCause); - const startedAt = yield* Clock.currentTimeMillis; - yield* record(`chatgpt.${event}.started`, properties); - return yield* task.pipe( - Effect.onExit((result) => - Effect.gen(function* () { - const endedAt = yield* Clock.currentTimeMillis; - const error = Exit.isFailure(result) - ? Cause.findErrorOption(result.cause) - : Option.none(); - const counts = - Exit.isSuccess(result) && - (event === "transfer" || options.telemetryFlow !== "primary_handoff") - ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) - : {}; - yield* record(`chatgpt.${event}.completed`, { - ...counts, - ...properties, - outcome: Exit.isSuccess(result) - ? "succeeded" - : Cause.hasInterruptsOnly(result.cause) - ? expiresAt !== undefined && endedAt >= expiresAt - ? "expired" - : "cancelled" - : "failed", - durationMs: Math.max(0, endedAt - startedAt), - ...(Option.isSome(error) && isSetupError(error.value) - ? { failureStage: error.value.operation } - : {}), - }); - }), - ), - ); - }); + ) { + if (Option.isNone(analytics)) return yield* task; + const record = (name: string, properties: Readonly>) => + analytics.value.record(name, properties).pipe(Effect.ignoreCause); + const startedAt = yield* Clock.currentTimeMillis; + yield* record(`chatgpt.${event}.started`, properties); + return yield* task.pipe( + Effect.onExit((result) => + Effect.gen(function* () { + const endedAt = yield* Clock.currentTimeMillis; + const error = Exit.isFailure(result) + ? Cause.findErrorOption(result.cause) + : Option.none(); + const counts = + Exit.isSuccess(result) && + (event === "transfer" || options.telemetryFlow !== "primary_handoff") + ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) + : {}; + yield* record(`chatgpt.${event}.completed`, { + ...counts, + ...properties, + outcome: Exit.isSuccess(result) + ? "succeeded" + : Cause.hasInterruptsOnly(result.cause) + ? expiresAt !== undefined && endedAt >= expiresAt + ? "expired" + : "cancelled" + : "failed", + durationMs: Math.max(0, endedAt - startedAt), + ...(Option.isSome(error) && isSetupError(error.value) + ? { failureStage: error.value.operation } + : {}), + }); + }), + ), + ); + }); const http = yield* HttpClient.HttpClient; const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId); const registrationStore = yield* ProviderCredentialStore.make( diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 33a0ce8edad3..1cdec303ebf0 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -543,33 +543,16 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => appendCustomCodexModels([], codexSettings.customModels); -const makePendingCodexProvider = ( +const makePendingCodexProvider = Effect.fn("CodexProvider.makePendingCodexProvider")(function* ( codexSettings: CodexSettings, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = emptyCodexModelsFromSettings(codexSettings); - - if (!codexSettings.enabled) { - return buildServerProvider({ - presentation: CODEX_PRESENTATION, - enabled: false, - checkedAt, - models, - skills: [], - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Codex is disabled in T3 Code settings.", - }, - }); - } +): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = emptyCodexModelsFromSettings(codexSettings); + if (!codexSettings.enabled) { return buildServerProvider({ presentation: CODEX_PRESENTATION, - enabled: true, + enabled: false, checkedAt, models, skills: [], @@ -578,10 +561,26 @@ const makePendingCodexProvider = ( version: null, status: "warning", auth: { status: "unknown" }, - message: "Codex provider status has not been checked in this session yet.", + message: "Codex is disabled in T3 Code settings.", }, }); + } + + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: true, + checkedAt, + models, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex provider status has not been checked in this session yet.", + }, }); +}); function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { readonly status: Exclude; diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 7a77aa6beb1a..0f42271c62d2 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -93,435 +93,436 @@ export const AntigravityDriver: ProviderDriver< configSchema: AntigravitySettings, defaultConfig: () => decodeSettings({}), usage: AntigravityUsage.antigravityUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const httpClient = yield* HttpClient.HttpClient; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const host = yield* ProviderHost.ProviderHost; - const selfInvocation = yield* resolveSelfInvocation(); - const installation = yield* AntigravityInstallation.AntigravityInstallation; - const loggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const modelCatalog = yield* ModelCatalog.ModelCatalog; - const currentCatalog = modelCatalog.current(DRIVER); - const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); - const settings = { ...config, enabled } satisfies AntigravitySettings; - const auth: AntigravityAuthConfig = { - authMethod: settings.authMethod, - apiKey: settings.apiKey, - gcpProject: settings.gcpProject, - gcpLocation: settings.gcpLocation, - }; - const authConfigIssue = antigravityAuthConfigIssue(auth); - const processEnvironment = yield* mergeProviderInstanceEnvironment(environment); - const userHome = resolveAntigravityUserHome( - yield* HostProcess.Platform, - processEnvironment, - yield* HostProcess.HomeDirectory, + create: Effect.fn("AntigravityDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const host = yield* ProviderHost.ProviderHost; + const selfInvocation = yield* resolveSelfInvocation(); + const installation = yield* AntigravityInstallation.AntigravityInstallation; + const loggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const modelCatalog = yield* ModelCatalog.ModelCatalog; + const currentCatalog = modelCatalog.current(DRIVER); + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + const settings = { ...config, enabled } satisfies AntigravitySettings; + const auth: AntigravityAuthConfig = { + authMethod: settings.authMethod, + apiKey: settings.apiKey, + gcpProject: settings.gcpProject, + gcpLocation: settings.gcpLocation, + }; + const authConfigIssue = antigravityAuthConfigIssue(auth); + const processEnvironment = yield* mergeProviderInstanceEnvironment(environment); + const userHome = resolveAntigravityUserHome( + yield* HostProcess.Platform, + processEnvironment, + yield* HostProcess.HomeDirectory, + ); + const directories = yield* resolveAntigravityInstanceDirectories( + host.paths.stateDir, + instanceId, + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not resolve the Antigravity profile directory.", + cause, + }), + ), + ); + const profileDirectory = directories.profile; + for (const directory of [ + directories.runtimeTemp, + path.join(profileDirectory, "antigravity-acp", "tmp"), + ]) { + yield* removeAntigravityRuntimeTempDirs(directory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), ); - const directories = yield* resolveAntigravityInstanceDirectories( - host.paths.stateDir, - instanceId, - ).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Path.Path, path), + } + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const classifyModels = (draft: ServerProviderDraft) => + currentCatalog.pipe( + Effect.map((catalog) => stampIdentity(ModelCatalog.applyModelCatalog(draft, catalog))), + ); + + const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( + input: Omit, + ): Effect.fn.Return< + AcpSessionRuntime.AcpSessionRuntime["Service"], + AcpError | ProviderSetupError, + Scope.Scope + > { + if (authConfigIssue !== null) { + return yield* new ProviderSetupError({ + instanceId, + operation: "configure", + detail: authConfigIssue, + }); + } + const executable = yield* installation.acquire(settings.binaryPath, processEnvironment).pipe( Effect.mapError( (cause) => - new ProviderDriverError({ - driver: DRIVER, + new ProviderSetupError({ instanceId, - detail: "Could not resolve the Antigravity profile directory.", + operation: "resolve", + detail: cause.detail, cause, }), ), ); - const profileDirectory = directories.profile; - for (const directory of [ - directories.runtimeTemp, - path.join(profileDirectory, "antigravity-acp", "tmp"), - ]) { - yield* removeAntigravityRuntimeTempDirs(directory).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - } - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const classifyModels = (draft: ServerProviderDraft) => - currentCatalog.pipe( - Effect.map((catalog) => stampIdentity(ModelCatalog.applyModelCatalog(draft, catalog))), - ); - - const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( - input: Omit, - ): Effect.fn.Return< - AcpSessionRuntime.AcpSessionRuntime["Service"], - AcpError | ProviderSetupError, - Scope.Scope - > { - if (authConfigIssue !== null) { - return yield* new ProviderSetupError({ - instanceId, - operation: "configure", - detail: authConfigIssue, - }); - } - const executable = yield* installation - .acquire(settings.binaryPath, processEnvironment) - .pipe( - Effect.mapError( - (cause) => - new ProviderSetupError({ - instanceId, - operation: "resolve", - detail: cause.detail, - cause, - }), - ), - ); - const profile = yield* prepareAntigravityProfile({ - profileDirectory, - baseEnv: processEnvironment, - auth, - userHome, - tempDirectory: directories.runtimeTemp, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError((cause) => - isNodeRuntimeUnavailableError(cause.cause) - ? new ProviderSetupError({ - instanceId, - operation: "start", - detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), - cause, - }) - : 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. - const runtimeTempDirectory = yield* Effect.acquireRelease( - fileSystem.makeTempDirectory({ directory: profile.tempDirectory, prefix: "run-" }).pipe( - Effect.mapError( - (cause) => - new ProviderSetupError({ - instanceId, - operation: "start", - detail: "Could not create an Antigravity runtime temp directory.", - cause, - }), - ), + const profile = yield* prepareAntigravityProfile({ + profileDirectory, + baseEnv: processEnvironment, + auth, + userHome, + tempDirectory: directories.runtimeTemp, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError((cause) => + isNodeRuntimeUnavailableError(cause.cause) + ? new ProviderSetupError({ + instanceId, + operation: "start", + detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), + cause, + }) + : 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. + const runtimeTempDirectory = yield* Effect.acquireRelease( + fileSystem.makeTempDirectory({ directory: profile.tempDirectory, prefix: "run-" }).pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId, + operation: "start", + detail: "Could not create an Antigravity runtime temp directory.", + cause, + }), ), - (directory) => - fileSystem - .remove(directory, { recursive: true, force: true }) - .pipe( - Effect.catch(() => - Effect.logWarning("Could not remove an Antigravity runtime temp directory."), - ), + ), + (directory) => + fileSystem + .remove(directory, { recursive: true, force: true }) + .pipe( + Effect.catch(() => + Effect.logWarning("Could not remove an Antigravity runtime temp directory."), ), - ); - const runtime = yield* makeAntigravityAcpRuntime({ - ...input, - authMethod: auth.authMethod, - childProcessSpawner: spawner, - spawn: buildAntigravityAcpSpawnInput({ - installation: executable, - profile, - cwd: input.cwd, - baseEnv: withAgentDeviceEnvironment(processEnvironment, input), - auth, - runtimeTempDirectory, - }), - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); - return { - ...runtime, - start: () => - runtime - .start() - .pipe( - Effect.tapError((cause): Effect.Effect => - input.onAuthorizationUrl === undefined && isAntigravitySignInRequiredError(cause) - ? provider.onAuthRequired - : Effect.void, - ), + ), + ); + const runtime = yield* makeAntigravityAcpRuntime({ + ...input, + authMethod: auth.authMethod, + childProcessSpawner: spawner, + spawn: buildAntigravityAcpSpawnInput({ + installation: executable, + profile, + cwd: input.cwd, + baseEnv: withAgentDeviceEnvironment(processEnvironment, input), + auth, + runtimeTempDirectory, + }), + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + return { + ...runtime, + start: () => + runtime + .start() + .pipe( + Effect.tapError((cause): Effect.Effect => + input.onAuthorizationUrl === undefined && isAntigravitySignInRequiredError(cause) + ? provider.onAuthRequired + : Effect.void, ), - }; - }); - - const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* ( - input: Pick, - ) { - const cwd = yield* fileSystem - .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" }) - .pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation: "start", - detail: "Could not create an Antigravity setup workspace.", - }), ), - ); - let sessionId: string | undefined; - yield* Effect.addFinalizer(() => - removeAntigravitySessionFiles({ - profileDirectory, - sessionId, - cwd, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), + }; + }); + + const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* ( + input: Pick, + ) { + const cwd = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" }) + .pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "start", + detail: "Could not create an Antigravity setup workspace.", + }), ), ); - const runtime = yield* makeRuntime({ + let sessionId: string | undefined; + yield* Effect.addFinalizer(() => + removeAntigravitySessionFiles({ + profileDirectory, + sessionId, cwd, - clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, - mcpServers: [], - ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), - }); - return { - ...runtime, - start: () => - runtime.start().pipe( - Effect.tap((started) => - Effect.sync(() => { - sessionId = started.sessionId; - }), - ), - ), - }; - }); - - const publishCatalog = ( - started: AcpSessionRuntime.AcpSessionRuntimeStartResult, - runtime: Pick, - ): Effect.Effect => - Effect.gen(function* () { - yield* provider.onSessionStarted(started); - yield* Stream.runForEach(runtime.getEvents(), (event) => { - if (event._tag === "EventStreamBarrier") { - return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); - } - if (event._tag === "ConfigOptionsUpdated") { - return provider.onConfigOptionsUpdated(event.configOptions); - } - return event._tag === "AvailableCommandsUpdated" - ? provider.onAvailableCommands(event.availableCommands) - : Effect.void; - }).pipe(Effect.forkScoped); - yield* runtime.drainEvents; - }).pipe(Effect.scoped); - - const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ - instanceId, - makeRuntime: makeDisposableRuntime, - onAuthenticated: publishCatalog, - onSignedOut: Effect.suspend(() => provider.onSignedOut), - usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const runtime = yield* makeRuntime({ + cwd, + clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, + mcpServers: [], + ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), }); + return { + ...runtime, + start: () => + runtime.start().pipe( + Effect.tap((started) => + Effect.sync(() => { + sessionId = started.sessionId; + }), + ), + ), + }; + }); - // The probe must not spawn. The agent is a PyInstaller one-file bundle - // that unpacks about 1 GB per launch, and the health check runs every - // minute. - const probe = Effect.gen(function* () { - yield* modelCatalog.refreshInBackground; - if (authConfigIssue !== null) { - return yield* new ProviderSetupError({ - instanceId, - operation: "configure", - detail: authConfigIssue, - }); + const publishCatalog = Effect.fn("AntigravityDriver.publishCatalog")(function* ( + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + runtime: Pick, + ) { + yield* provider.onSessionStarted(started); + yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); } - const executable = yield* installation - .resolve(settings.binaryPath, processEnvironment) - .pipe( - Effect.mapError( - (cause) => - new ProviderSetupError({ - instanceId, - operation: "resolve", - detail: cause.detail, - cause, - }), - ), - ); - return { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - promptCapabilities: { image: true, audio: true, embeddedContext: true }, - sessionCapabilities: { list: {}, resume: {} }, - }, - authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], - agentInfo: { - name: "antigravity-acp", - title: "Google Antigravity", - version: executable.version ?? "unknown", - }, - }; - }); + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }).pipe(Effect.forkScoped); + yield* runtime.drainEvents; + }, Effect.scoped); - const usage = yield* makeAntigravityUsageProbe({ - profileDirectory, - authMethod: auth.authMethod, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(HttpClient.HttpClient, httpClient), - ); - const provider = yield* makeAntigravityProvider(settings, { - stampIdentity: classifyModels, - probe, - probeUsage: usage.probe, - clearUsage: usage.clear, - auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, - supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.orElseSucceed(() => false), - ), - }).pipe( + const authFlow: AntigravityAuth = yield* makeAntigravityAuth({ + instanceId, + makeRuntime: makeDisposableRuntime, + onAuthenticated: publishCatalog, + onSignedOut: Effect.suspend(() => provider.onSignedOut), + usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), + }); + + // The probe must not spawn. The agent is a PyInstaller one-file bundle + // that unpacks about 1 GB per launch, and the health check runs every + // minute. + const probe = Effect.gen(function* () { + yield* modelCatalog.refreshInBackground; + if (authConfigIssue !== null) { + return yield* new ProviderSetupError({ + instanceId, + operation: "configure", + detail: authConfigIssue, + }); + } + const executable = yield* installation.resolve(settings.binaryPath, processEnvironment).pipe( Effect.mapError( (cause) => - new ProviderDriverError({ - driver: DRIVER, + new ProviderSetupError({ instanceId, - detail: "Could not prepare the Antigravity provider status.", + operation: "resolve", + detail: cause.detail, cause, }), ), ); - const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); - const orchestrationAdapter = yield* makeAntigravityAdapterV2({ - instanceId, - selfInvocation, - makeRuntime, - withProcess: authFlow.withProcess, - defaultModel, - onSessionStarted: provider.onSessionStarted, - onSessionEvent: (event) => { - if (event._tag === "ConfigOptionsUpdated") { - return provider.onConfigOptionsUpdated(event.configOptions); - } - return event._tag === "AvailableCommandsUpdated" - ? provider.onAvailableCommands(event.availableCommands) - : Effect.void; + return { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, + sessionCapabilities: { list: {}, resume: {} }, }, - continuationRequests, - nativeLogging: (threadId) => - makeNativeLogger({ - nativeEventLogger: loggers.native, - provider: DRIVER, - threadId, - }), - }); - const textGeneration = yield* makeAntigravityTextGeneration({ - profileDirectory, - defaultModel, - withProcess: authFlow.withProcess, - makeRuntime: (cwd) => - makeRuntime({ - cwd, - clientInfo: { name: "t3-code-text", version: "0.0.0" }, - mcpServers: [], - }), - }); - - const refreshModels = Effect.fn("AntigravityDriver.refreshModels")( - function* () { - const processScope = yield* Scope.make(); - yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); - yield* authFlow - .withProcess( - Scope.close(processScope, Exit.void), - Effect.gen(function* () { - const runtime = yield* makeDisposableRuntime({}); - const started = yield* runtime.start(); - yield* publishCatalog(started, runtime); - }), - ) - .pipe(Effect.provideService(Scope.Scope, processScope)); + authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], + agentInfo: { + name: "antigravity-acp", + title: "Google Antigravity", + version: executable.version ?? "unknown", }, - Effect.scoped, - Effect.timeoutOrElse({ - duration: "90 seconds", - orElse: () => - Effect.fail( - new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: "Antigravity model refresh timed out. Try again or check Google sign-in.", - }), - ), + }; + }); + + const usage = yield* makeAntigravityUsageProbe({ + profileDirectory, + authMethod: auth.authMethod, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + const provider = yield* makeAntigravityProvider(settings, { + stampIdentity: classifyModels, + probe, + probeUsage: usage.probe, + clearUsage: usage.clear, + auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) }, + supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.orElseSucceed(() => false), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not prepare the Antigravity provider status.", + cause, + }), + ), + ); + const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); + const orchestrationAdapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation, + makeRuntime, + withProcess: authFlow.withProcess, + defaultModel, + onSessionStarted: provider.onSessionStarted, + onSessionEvent: (event) => { + if (event._tag === "ConfigOptionsUpdated") { + return provider.onConfigOptionsUpdated(event.configOptions); + } + return event._tag === "AvailableCommandsUpdated" + ? provider.onAvailableCommands(event.availableCommands) + : Effect.void; + }, + continuationRequests, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: loggers.native, + provider: DRIVER, + threadId, }), - Effect.tapError((cause) => - isAntigravitySignInRequiredError(cause) ? provider.onAuthRequired : Effect.void, - ), - Effect.mapError((cause) => - cause._tag === "ProviderDriverError" - ? cause - : new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: isAntigravitySignInRequiredError(cause) - ? "Sign in to Antigravity in provider settings before refreshing models." - : cause._tag === "ProviderSetupError" && - (cause.operation === "configure" || cause.operation === "start") - ? cause.detail - : "Could not refresh Antigravity models. The previous model list is unchanged.", - cause, - }), - ), - ); + }); + const textGeneration = yield* makeAntigravityTextGeneration({ + profileDirectory, + defaultModel, + withProcess: authFlow.withProcess, + makeRuntime: (cwd) => + makeRuntime({ + cwd, + clientInfo: { name: "t3-code-text", version: "0.0.0" }, + mcpServers: [], + }), + }); - return { - instanceId, - driverKind: DRIVER, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot: provider.snapshot, - snapshotForCwd: (cwd) => - !enabled - ? provider.snapshot.getSnapshot - : discoverAntigravitySkills({ cwd, userHome }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: "Could not read Antigravity workspace skills.", - cause, - }), - ), + const refreshModels = Effect.fn("AntigravityDriver.refreshModels")( + function* () { + const processScope = yield* Scope.make(); + yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit)); + yield* authFlow + .withProcess( + Scope.close(processScope, Exit.void), + Effect.gen(function* () { + const runtime = yield* makeDisposableRuntime({}); + const started = yield* runtime.start(); + yield* publishCatalog(started, runtime); + }), + ) + .pipe(Effect.provideService(Scope.Scope, processScope)); + }, + Effect.scoped, + Effect.timeoutOrElse({ + duration: "90 seconds", + orElse: () => + Effect.fail( + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Antigravity model refresh timed out. Try again or check Google sign-in.", + }), + ), + }), + Effect.tapError((cause) => + isAntigravitySignInRequiredError(cause) ? provider.onAuthRequired : Effect.void, + ), + Effect.mapError((cause) => + cause._tag === "ProviderDriverError" + ? cause + : new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: isAntigravitySignInRequiredError(cause) + ? "Sign in to Antigravity in provider settings before refreshing models." + : cause._tag === "ProviderSetupError" && + (cause.operation === "configure" || cause.operation === "start") + ? cause.detail + : "Could not refresh Antigravity models. The previous model list is unchanged.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: provider.snapshot, + snapshotForCwd: (cwd) => + !enabled + ? provider.snapshot.getSnapshot + : discoverAntigravitySkills({ cwd, userHome }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not read Antigravity workspace skills.", + cause, + }), ), - orchestrationAdapter, - textGeneration, - auth: authFlow.controller, - refreshModels, - } satisfies ProviderInstance; - }), + ), + orchestrationAdapter, + textGeneration, + auth: authFlow.controller, + refreshModels, + } satisfies ProviderInstance; + }), }; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4e5fe8e10480..176c7332fb47 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -113,288 +113,293 @@ export const ClaudeDriver: ProviderDriver decodeClaudeSettings({}), usage: claudeUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // The server cwd is $HOME in packaged desktop builds, and `claude` - // wrappers that scope themselves to their cwd refuse to run there. - const { stateDir } = yield* ServerConfig.ServerConfig; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const catalogService = yield* ModelCatalog.ModelCatalog; - const modelCatalog = catalogService - .current(DRIVER_KIND) - .pipe(Effect.map(resolveClaudeModelCatalog)); - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const effectiveConfig = { - ...config, - enabled, - binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), - } satisfies ClaudeSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ); - const continuationGroupKey = yield* makeClaudeContinuationGroupKey( - effectiveConfig, - processEnv, - ); - const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); - const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( - effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim() - ? configDir - : undefined, - ); - const stampIdentity = withInstanceIdentity({ + create: Effect.fn("ClaudeDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The server cwd is $HOME in packaged desktop builds, and `claude` + // wrappers that scope themselves to their cwd refuse to run there. + const { stateDir } = yield* ServerConfig.ServerConfig; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const catalogService = yield* ModelCatalog.ModelCatalog; + const modelCatalog = catalogService + .current(DRIVER_KIND) + .pipe(Effect.map(resolveClaudeModelCatalog)); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), + } satisfies ClaudeSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig, processEnv); + const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); + const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( + effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim() + ? configDir + : undefined, + ); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey, + }); + + const scopedLimitNames = yield* makeClaudeScopedLimitNames; + const orchestrationAdapter = yield* createClaudeAdapterV2( + { instanceId, - driverKind: DRIVER_KIND, displayName, accentColor, - continuationGroupKey, - }); - - const scopedLimitNames = yield* makeClaudeScopedLimitNames; - const orchestrationAdapter = yield* createClaudeAdapterV2( - { - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }, - { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Claude orchestration adapter.", - cause, - }), - ), - ); - const textGeneration = yield* makeClaudeTextGeneration( - effectiveConfig, - processEnv, - modelCatalog, - ); + environment, + enabled, + config, + }, + { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Claude orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeClaudeTextGeneration( + effectiveConfig, + processEnv, + modelCatalog, + ); - // Per-instance capabilities cache: keyed on binary + resolved HOME so - // account-specific probes never share auth metadata across instances. - // A transient miss may reuse the last account once, but its usage windows keep their age. - const lastGoodCapabilities = yield* Ref.make(undefined); - const capabilitiesProbeCache = yield* Cache.makeWith( - () => - probeClaudeCapabilities(effectiveConfig, processEnv, stateDir).pipe( - Effect.flatMap((capabilities) => - ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( - Effect.map((workspaceId) => ({ - _tag: "fresh" as const, - capabilities: - workspaceId === undefined ? capabilities : { ...capabilities, workspaceId }, - })), - ), + // Per-instance capabilities cache: keyed on binary + resolved HOME so + // account-specific probes never share auth metadata across instances. + // A transient miss may reuse the last account once, but its usage windows keep their age. + const lastGoodCapabilities = yield* Ref.make(undefined); + const capabilitiesProbeCache = yield* Cache.makeWith( + () => + probeClaudeCapabilities(effectiveConfig, processEnv, stateDir).pipe( + Effect.flatMap((capabilities) => + ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( + Effect.map((workspaceId) => ({ + _tag: "fresh" as const, + capabilities: + workspaceId === undefined ? capabilities : { ...capabilities, workspaceId }, + })), ), - Effect.tap(({ capabilities }) => Ref.set(lastGoodCapabilities, capabilities)), - Effect.catch((error) => - Ref.getAndSet(lastGoodCapabilities, undefined).pipe( - Effect.flatMap((capabilities) => - capabilities === undefined - ? Effect.fail(error) - : ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( - Effect.flatMap((workspaceId) => - workspaceId === capabilities.workspaceId - ? Effect.succeed({ - _tag: "fallback" as const, - capabilities: withoutUsage(capabilities), - }) - : Effect.fail(error), - ), + ), + Effect.tap(({ capabilities }) => Ref.set(lastGoodCapabilities, capabilities)), + Effect.catch((error) => + Ref.getAndSet(lastGoodCapabilities, undefined).pipe( + Effect.flatMap((capabilities) => + capabilities === undefined + ? Effect.fail(error) + : ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( + Effect.flatMap((workspaceId) => + workspaceId === capabilities.workspaceId + ? Effect.succeed({ + _tag: "fallback" as const, + capabilities: withoutUsage(capabilities), + }) + : Effect.fail(error), ), - ), + ), ), ), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), ), - { - capacity: 1, - timeToLive: (exit) => - // Only a fresh probe earns the TTL; a failure or its fallback retries on the next check. - Exit.isSuccess(exit) && exit.value._tag === "fresh" - ? CAPABILITIES_PROBE_TTL - : Duration.zero, - }, - ); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( - effectiveConfig, - stateDir, - processEnv, - ); - const resolveCapabilities = Cache.get(capabilitiesProbeCache, capabilitiesCacheKey).pipe( - Effect.map((result) => result.capabilities), - ); + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + { + capacity: 1, + timeToLive: (exit) => + // Only a fresh probe earns the TTL; a failure or its fallback retries on the next check. + Exit.isSuccess(exit) && exit.value._tag === "fresh" + ? CAPABILITIES_PROBE_TTL + : Duration.zero, + }, + ); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( + effectiveConfig, + stateDir, + processEnv, + ); + const resolveCapabilities = Cache.get(capabilitiesProbeCache, capabilitiesCacheKey).pipe( + Effect.map((result) => result.capabilities), + ); - const checkProvider = catalogService.refreshInBackground.pipe( - Effect.andThen( - modelCatalog.pipe( - Effect.flatMap((catalog) => - checkClaudeProviderStatus( - effectiveConfig, - () => resolveCapabilities, - processEnv, - stateDir, - catalog, - scopedLimitNames, - (version) => - ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ), + const checkProvider = catalogService.refreshInBackground.pipe( + Effect.andThen( + modelCatalog.pipe( + Effect.flatMap((catalog) => + checkClaudeProviderStatus( + effectiveConfig, + () => resolveCapabilities, + processEnv, + stateDir, + catalog, + scopedLimitNames, + (version) => + ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), ), - Effect.map(stampIdentity), ), + Effect.map(stampIdentity), ), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - modelCatalog.pipe( - Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)), - Effect.map(stampIdentity), - ), - checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, - cause, + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + modelCatalog.pipe( + Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)), + Effect.map(stampIdentity), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); - // Same rules as Codex: serialised on the config directory that holds the - // login, one request id kept until Claude answers (a cooldown or rate - // limit is an answer), then a re-probe. - const consumeResetCredit: NonNullable = () => - Effect.gen(function* () { - const current = yield* snapshot.getSnapshot; - const grantId = current.usageLimits?.resetCredits?.nextCreditId; - if (!grantId || !current.version) return "noCredit" as const; - const version = current.version; - return yield* resetCreditCoordinator.redeem( - configDir, - (requestId) => - ClaudeResetCredits.consumeClaudeResetCredit({ - configDir, - accountConfigPath, - version, - grantId, - requestId, - }), - ClaudeResetCredits.isSettledClaudeResetCreditFailure, - ); - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: - cause._tag === "ClaudeResetCreditError" - ? cause.message - : "Claude could not redeem the reset.", - cause, - }), - ), - Effect.tap((outcome) => - Effect.gen(function* () { - const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; - yield* Cache.invalidateAll(capabilitiesProbeCache); - const refreshed = yield* snapshot.refresh; - const after = refreshed.usageLimits?.checkedAt; - if ( - outcome === "reset" && - (after === undefined || - after === before || - refreshed.usageLimits?.unavailable?.reason === "probeFailed") - ) { - return yield* new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: - "The reset was applied, but Claude could not confirm the new limits. Refresh to check.", - }); - } + // Same rules as Codex: serialised on the config directory that holds the + // login, one request id kept until Claude answers (a cooldown or rate + // limit is an answer), then a re-probe. + const consumeResetCredit: NonNullable = Effect.fn( + "ClaudeDriver.consumeResetCredit", + )( + function* () { + const current = yield* snapshot.getSnapshot; + const grantId = current.usageLimits?.resetCredits?.nextCreditId; + if (!grantId || !current.version) return "noCredit" as const; + const version = current.version; + return yield* resetCreditCoordinator.redeem( + configDir, + (requestId) => + ClaudeResetCredits.consumeClaudeResetCredit({ + configDir, + accountConfigPath, + version, + grantId, + requestId, }), - ), + ClaudeResetCredits.isSettledClaudeResetCreditFailure, ); + }, + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + cause._tag === "ClaudeResetCreditError" + ? cause.message + : "Claude could not redeem the reset.", + cause, + }), + ), + Effect.tap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + yield* Cache.invalidateAll(capabilitiesProbeCache); + const refreshed = yield* snapshot.refresh; + const after = refreshed.usageLimits?.checkedAt; + if ( + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") + ) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "The reset was applied, but Claude could not confirm the new limits. Refresh to check.", + }); + } + }), + ), + ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity: { - ...fallbackContinuationIdentity, - continuationKey: continuationGroupKey, - }, - displayName, - accentColor, - enabled, - snapshot, - invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), - snapshotForCwd: (cwd: string) => - snapshot.getSnapshot.pipe( - Effect.flatMap((machineSnapshot) => - probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), - ), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity: { + ...fallbackContinuationIdentity, + continuationKey: continuationGroupKey, + }, + displayName, + accentColor, + enabled, + snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), + snapshotForCwd: (cwd: string) => + snapshot.getSnapshot.pipe( + Effect.flatMap((machineSnapshot) => + probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv), ), - orchestrationAdapter, - textGeneration, - consumeResetCredit, - } satisfies ProviderInstance; - }), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + orchestrationAdapter, + textGeneration, + consumeResetCredit, + } satisfies ProviderInstance; + }), }; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2388213eed30..28e23b2f7e9e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -106,246 +106,252 @@ export const CodexDriver: ProviderDriver decodeCodexSettings({}), usage: codexUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - if (config.setupMode === "managed") - return yield* makeManagedCodexProvider({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const modelCatalog = yield* ModelCatalog.ModelCatalog; - const currentCatalog = modelCatalog.current(DRIVER_KIND); - const processEnv = yield* mergeProviderInstanceEnvironment(environment); - const homeLayout = yield* resolveCodexHomeLayout(config); - const continuationIdentity = codexContinuationIdentity(homeLayout); - const stampIdentity = withInstanceIdentity({ + create: Effect.fn("CodexDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + if (config.setupMode === "managed") + return yield* makeManagedCodexProvider({ instanceId, - driverKind: DRIVER_KIND, displayName, accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - yield* materializeCodexShadowHome(homeLayout).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: cause.message, - cause, - }), - ), - ); - const effectiveConfig = { - ...config, + environment, enabled, - binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), - homePath: homeLayout.effectiveHomePath ?? "", - } satisfies CodexSettings; - const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect( - makeCodexMaintenanceResolver(homeLayout.sharedHomePath), - { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }, - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ), - ); - - const orchestrationAdapter = yield* createCodexAdapterV2( + config, + }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const modelCatalog = yield* ModelCatalog.ModelCatalog; + const currentCatalog = modelCatalog.current(DRIVER_KIND); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const homeLayout = yield* resolveCodexHomeLayout(config); + const continuationIdentity = codexContinuationIdentity(homeLayout); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: cause.message, + cause, + }), + ), + ); + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), + homePath: homeLayout.effectiveHomePath ?? "", + } satisfies CodexSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect( + makeCodexMaintenanceResolver(homeLayout.sharedHomePath), { - instanceId, - displayName, - accentColor, - environment, - enabled, - config, + binaryPath: effectiveConfig.binaryPath, + env: processEnv, }, - { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Codex orchestration adapter.", - cause, - }), - ), - ); - - // Kick the TTL-gated manifest refresh in the background and classify with the in-memory - // manifest, so a slow or hung fetch never delays the provider check. - const checkProvider = modelCatalog.refreshInBackground.pipe( - Effect.andThen( - Effect.zipWith( - checkCodexProviderStatus(effectiveConfig, undefined, processEnv), - currentCatalog, - (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), - { concurrent: true }, - ), - ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), - ); - const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - Effect.zipWith( - makePendingCodexProvider(settings.provider), - currentCatalog, - (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), - ), - checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, - cause, + ), + ); + + const orchestrationAdapter = yield* createCodexAdapterV2( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Codex orchestration adapter.", + cause, + }), + ), + ); + + // Kick the TTL-gated manifest refresh in the background and classify with the in-memory + // manifest, so a slow or hung fetch never delays the provider check. + const checkProvider = modelCatalog.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + currentCatalog, + (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), + { concurrent: true }, + ), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + Effect.zipWith( + makePendingCodexProvider(settings.provider), + currentCatalog, + (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), - ); - const textGeneration = yield* makeCodexTextGeneration( - effectiveConfig, - processEnv, - snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), - ); - const snapshotForCwd = (cwd: string) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - probeCodexSkillsForCwd({ - binaryPath: effectiveConfig.binaryPath, - homePath: effectiveConfig.homePath, - launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), - cwd, - environment: processEnv, - }).pipe( - Effect.scoped, - Effect.timeout("20 seconds"), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ]).pipe( - Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to probe Codex skills for '${cwd}'`, - cause, - }), - ), - ); - - // 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. - const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; - const consumeResetCredit: NonNullable = () => - resetCreditCoordinator - .redeem(accountKey, (idempotencyKey) => - Effect.gen(function* () { - const { client } = yield* withCodexAppServerClient({ - binaryPath: effectiveConfig.binaryPath, - homePath: effectiveConfig.homePath, - launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), - cwd: process.cwd(), - environment: processEnv, - }); - const response = yield* client.request("account/rateLimitResetCredit/consume", { - idempotencyKey, - }); - return response.outcome; - }).pipe(Effect.scoped, Effect.timeout("20 seconds")), - ) - .pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + const textGeneration = yield* makeCodexTextGeneration( + effectiveConfig, + processEnv, + snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + ); + const snapshotForCwd = (cwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeCodexSkillsForCwd({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.timeout("20 seconds"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), Effect.mapError( (cause) => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: "Codex could not redeem the reset credit.", + detail: `Failed to probe Codex skills for '${cwd}'`, cause, }), ), - // The windows just changed; re-probe so the snapshot says so. A - // failed probe republishes the pre-redemption limits rather than - // marking them failed, so "confirmed" means `checkedAt` moved past - // what was published before the redemption started. - Effect.tap((outcome) => - Effect.gen(function* () { - const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; - const refreshed = yield* snapshot.refresh; - const after = refreshed.usageLimits?.checkedAt; - if ( - outcome === "reset" && - (after === undefined || - after === before || - refreshed.usageLimits?.unavailable?.reason === "probeFailed") - ) { - return yield* new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: - "The reset was applied, but Codex could not confirm the new limits. Refresh to check.", - }); - } - }), - ), ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - snapshotForCwd, - consumeResetCredit, - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), + // 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. + const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; + const consumeResetCredit: NonNullable = () => + resetCreditCoordinator + .redeem(accountKey, (idempotencyKey) => + Effect.gen(function* () { + const { client } = yield* withCodexAppServerClient({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd: process.cwd(), + environment: processEnv, + }); + const response = yield* client.request("account/rateLimitResetCredit/consume", { + idempotencyKey, + }); + return response.outcome; + }).pipe(Effect.scoped, Effect.timeout("20 seconds")), + ) + .pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Codex could not redeem the reset credit.", + cause, + }), + ), + // The windows just changed; re-probe so the snapshot says so. A + // failed probe republishes the pre-redemption limits rather than + // marking them failed, so "confirmed" means `checkedAt` moved past + // what was published before the redemption started. + Effect.tap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + const refreshed = yield* snapshot.refresh; + const after = refreshed.usageLimits?.checkedAt; + if ( + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") + ) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "The reset was applied, but Codex could not confirm the new limits. Refresh to check.", + }); + } + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd, + consumeResetCredit, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), }; diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 1493c283c2ad..26db5d8d46f3 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -433,47 +433,49 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* cache?.set(canonical, { fingerprint, candidates }); return candidates; }); - const walk = (directory: string, root: string): Effect.Effect => - Effect.gen(function* () { - const names = yield* fileSystem.readDirectory(directory).pipe( - Effect.catchTags({ - PlatformError: (cause) => { - if (cause.reason._tag !== "NotFound") errors.push(directory); - return Effect.succeed(null); - }, - }), - ); - if (names === null) return; - names.sort((a, b) => a.localeCompare(b)); - for (const name of names) { - const path = pathService.join(directory, name); - // Like a directory listing's entry types: symlinks are neither files nor directories. - const isLink = Exit.isSuccess(yield* Effect.exit(fileSystem.readLink(path))); - const type = isLink - ? "SymbolicLink" - : yield* fileSystem.stat(path).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => null), - ); - if (type === "Directory") { - yield* walk(path, root); - } else if ((type === "File" || type === null) && name.endsWith(".db")) { - const candidates = yield* readFile(path).pipe( - Effect.catch(() => { - errors.push(path); - return Effect.succeed(null); - }), + const walk = Effect.fn("antigravityUsageReader.walk")(function* ( + directory: string, + root: string, + ): Effect.fn.Return { + const names = yield* fileSystem.readDirectory(directory).pipe( + Effect.catchTags({ + PlatformError: (cause) => { + if (cause.reason._tag !== "NotFound") errors.push(directory); + return Effect.succeed(null); + }, + }), + ); + if (names === null) return; + names.sort((a, b) => a.localeCompare(b)); + for (const name of names) { + const path = pathService.join(directory, name); + // Like a directory listing's entry types: symlinks are neither files nor directories. + const isLink = Exit.isSuccess(yield* Effect.exit(fileSystem.readLink(path))); + const type = isLink + ? "SymbolicLink" + : yield* fileSystem.stat(path).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => null), ); - if (candidates === null) continue; - const fileIndex = files.length; - files.push({ root, path, records: [] }); - for (const [index, candidate] of candidates.entries()) { - append(candidate, fileIndex); - if (index % 256 === 255) yield* Effect.yieldNow; - } + if (type === "Directory") { + yield* walk(path, root); + } else if ((type === "File" || type === null) && name.endsWith(".db")) { + const candidates = yield* readFile(path).pipe( + Effect.catch(() => { + errors.push(path); + return Effect.succeed(null); + }), + ); + if (candidates === null) continue; + const fileIndex = files.length; + files.push({ root, path, records: [] }); + for (const [index, candidate] of candidates.entries()) { + append(candidate, fileIndex); + if (index % 256 === 255) yield* Effect.yieldNow; } } - }); + } + }); for (const root of roots) yield* walk(root, root); if (cache !== undefined) { for (const key of cache.keys()) if (!visited.has(key)) cache.delete(key); diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index 24f176dd7095..5a730c5baf8d 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -267,8 +268,8 @@ export const makeProviderAuthService = Effect.gen(function* () { }), ); }), - subscribe: (input, ownerSessionId) => - Effect.gen(function* () { + subscribe: Function.flow( + Effect.fn("ProviderAuthService.subscribe")(function* (input, ownerSessionId) { const changes = yield* registry.subscribeChanges; const initial = yield* getController(input.instanceId, "subscribe"); return Stream.concat( @@ -280,7 +281,9 @@ export const makeProviderAuthService = Effect.gen(function* () { Stream.changesWith((previous, next) => previous === next), Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), ); - }).pipe(Stream.unwrap), + }), + Stream.unwrap, + ), tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( function* (input) { const instance = yield* registry.getInstance(input.instanceId); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index e5bf808faa73..1659e8f8fa14 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -70,98 +70,97 @@ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; }; -const buildEntry = (input: { +const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; readonly instanceId: ProviderInstanceId; readonly rawInstanceId: string; readonly entry: ProviderInstanceConfig; -}): Effect.Effect< +}): Effect.fn.Return< | { readonly kind: "live"; readonly live: LiveEntry } | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, never, R -> => - Effect.gen(function* () { - const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; - const driver = driversById.get(entry.driver); - if (!driver) { - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' is not registered in this build.`, - }), - }; - } - - const decoder = Schema.decodeUnknownEffect(driver.configSchema); - const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); - if (decodeResult._tag === "Failure") { - const issue = decodeResult.failure; - const detail = issue.message ?? String(issue); - yield* Effect.logError("Failed to decode provider instance config", { - instanceId: rawInstanceId, - driver: entry.driver, - detail, - }); - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, - }), - }; - } - - const typedConfig = decodeResult.success; - const childScope = yield* Scope.make(); - yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); +> { + const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + const driver = driversById.get(entry.driver); + if (!driver) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' is not registered in this build.`, + }), + }; + } - const createResult = yield* driver - .create({ + const decoder = Schema.decodeUnknownEffect(driver.configSchema); + const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); + if (decodeResult._tag === "Failure") { + const issue = decodeResult.failure; + const detail = issue.message ?? String(issue); + yield* Effect.logError("Failed to decode provider instance config", { + instanceId: rawInstanceId, + driver: entry.driver, + detail, + }); + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, instanceId, displayName: entry.displayName, accentColor: entry.accentColor, - environment: entry.environment ?? [], - enabled: resolveEntryEnabled(entry, typedConfig), - config: typedConfig, - }) - .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); - if (createResult._tag === "Failure") { - yield* Effect.logError("Failed to create provider instance", { - instanceId: rawInstanceId, - driver: entry.driver, - detail: createResult.failure.detail, - }); - yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); - return { - kind: "unavailable" as const, - snapshot: yield* buildUnavailableProviderSnapshot({ - driverKind: entry.driver, - instanceId, - displayName: entry.displayName, - accentColor: entry.accentColor, - reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, - }), - }; - } + reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, + }), + }; + } + + const typedConfig = decodeResult.success; + const childScope = yield* Scope.make(); + yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); + const createResult = yield* driver + .create({ + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + environment: entry.environment ?? [], + enabled: resolveEntryEnabled(entry, typedConfig), + config: typedConfig, + }) + .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); + if (createResult._tag === "Failure") { + yield* Effect.logError("Failed to create provider instance", { + instanceId: rawInstanceId, + driver: entry.driver, + detail: createResult.failure.detail, + }); + yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); return { - kind: "live" as const, - live: { - instance: createResult.success, - scope: childScope, - entry, - }, + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, + }), }; - }); + } + + return { + kind: "live" as const, + live: { + instance: createResult.success, + scope: childScope, + entry, + }, + }; +}); const makeReconcile = (input: { readonly state: RegistryState; @@ -263,67 +262,68 @@ const makeReconcile = (input: { }); }; -export const makeProviderInstanceRegistry = (input: { +export const makeProviderInstanceRegistry = Effect.fn( + "ProviderInstanceRegistry.makeProviderInstanceRegistry", +)(function* (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; -}): Effect.Effect< +}): Effect.fn.Return< { readonly registry: ProviderInstanceRegistry["Service"]; readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; }, never, R | Scope.Scope -> => - Effect.gen(function* () { - const driversById = new Map>( - input.drivers.map((driver) => [driver.driverKind, driver]), - ); +> { + const driversById = new Map>( + input.drivers.map((driver) => [driver.driverKind, driver]), + ); - // Capture the enclosing scope so per-instance child scopes can be - // attached to it at `reconcile` time. Without this, `reconcile` - // called later (e.g. from the hydration layer) would attach child - // scopes to the *caller's* scope instead of the registry's. - const parentScope = yield* Scope.Scope; + // Capture the enclosing scope so per-instance child scopes can be + // attached to it at `reconcile` time. Without this, `reconcile` + // called later (e.g. from the hydration layer) would attach child + // scopes to the *caller's* scope instead of the registry's. + const parentScope = yield* Scope.Scope; - const driverContext = yield* Effect.context(); + const driverContext = yield* Effect.context(); - const entries = yield* Ref.make>(new Map()); - const unavailable = yield* Ref.make>(new Map()); - const changes = yield* PubSub.unbounded(); - yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); + const entries = yield* Ref.make>(new Map()); + const unavailable = yield* Ref.make>(new Map()); + const changes = yield* PubSub.unbounded(); + yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); - const state: RegistryState = { entries, unavailable, changes }; - const reconcileWithR = makeReconcile({ state, driversById, parentScope }); - const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = - (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); + const state: RegistryState = { entries, unavailable, changes }; + const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = + (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); - yield* reconcile(input.configMap); + yield* reconcile(input.configMap); - const registry: ProviderInstanceRegistry["Service"] = { - getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), - listInstances: Ref.get(entries).pipe( - Effect.map( - (map) => - Array.from(map.values(), (live) => live.instance) as ReadonlyArray, - ), + const registry: ProviderInstanceRegistry["Service"] = { + getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), + listInstances: Ref.get(entries).pipe( + Effect.map( + (map) => + Array.from(map.values(), (live) => live.instance) as ReadonlyArray, ), - listUnavailable: Ref.get(unavailable).pipe( - Effect.map((map) => Array.from(map.values()) as ReadonlyArray), - ), - get streamChanges() { - return Stream.fromPubSub(changes); - }, - get subscribeChanges() { - return PubSub.subscribe(changes); - }, - }; + ), + listUnavailable: Ref.get(unavailable).pipe( + Effect.map((map) => Array.from(map.values()) as ReadonlyArray), + ), + get streamChanges() { + return Stream.fromPubSub(changes); + }, + get subscribeChanges() { + return PubSub.subscribe(changes); + }, + }; - const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { - reconcile, - }; + const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { + reconcile, + }; - return { registry, mutator }; - }); + return { registry, mutator }; +}); export const layer = (input: { readonly drivers: ReadonlyArray>; diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 0a3b4e609171..c459f670fcaf 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -454,21 +454,22 @@ export const layer = Layer.effect( liveSubsRef, ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); - const persistProvider = (provider: ServerProvider) => - Effect.gen(function* () { - const key = snapshotInstanceKey(provider); - const filePath = yield* resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - instanceId: key, - }).pipe(Effect.provideService(Path.Path, path)); - const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; - yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.tapError(Effect.logError), - Effect.ignore, - ); - }); + const persistProvider = Effect.fn("ProviderRegistry.persistProvider")(function* ( + provider: ServerProvider, + ) { + const key = snapshotInstanceKey(provider); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: key, + }).pipe(Effect.provideService(Path.Path, path)); + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; + yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ); + }); const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( provider: ServerProvider, diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 4736fbe49708..fbc3efa2e5d4 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -122,25 +122,28 @@ export function claudeResetCreditsToContract( }; } -const readJson = (schema: S, file: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.readFileString(file).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), - ); - }); +const readJson = Effect.fn("claudeResetCredits.readJson")(function* ( + schema: S, + file: string, +) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), + ); +}); -const readAccessToken = (configDir: string) => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "darwin") return undefined; - const path = yield* Path.Path; - const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); - return credentials.claudeAiOauth?.accessToken?.trim() || undefined; - }); +const readAccessToken = Effect.fn("claudeResetCredits.readAccessToken")(function* ( + configDir: string, +) { + if ((yield* HostProcess.Platform) === "darwin") return undefined; + const path = yield* Path.Path; + const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); + return credentials.claudeAiOauth?.accessToken?.trim() || undefined; +}); const withClaudeHeaders = (token: string, version: string) => HttpClientRequest.setHeaders({ diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts index f2fc03659be0..c7db6bdf76aa 100644 --- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -35,13 +35,13 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( return next; }); - const withCommandLock: ProviderMaintenanceCommandCoordinatorShape["withCommandLock"] = ({ - targetKey, - lockKey, - onQueued, - run, - }) => - Effect.gen(function* () { + const withCommandLock: ProviderMaintenanceCommandCoordinatorShape["withCommandLock"] = + Effect.fn("providerMaintenanceCommandCoordinator.withCommandLock")(function* ({ + targetKey, + lockKey, + onQueued, + run, + }) { const acquired = yield* acquireTarget(targetKey); if (!acquired) { return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index d2e5a70084de..486b7fd41046 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -108,8 +108,8 @@ export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCa }, ); -export const readProviderStatusCache = (filePath: string) => - Effect.gen(function* () { +export const readProviderStatusCache = Effect.fn("providerStatusCache.readProviderStatusCache")( + function* (filePath: string) { const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); if (!exists) { @@ -132,7 +132,8 @@ export const readProviderStatusCache = (filePath: string) => onSuccess: Effect.succeed, }), ); - }); + }, +); export const writeProviderStatusCache = (input: { readonly filePath: string; diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index 95e24cbdc656..45532eeb0550 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -58,24 +58,25 @@ export const make = Effect.gen(function* () { }); }); - const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => - Effect.gen(function* () { - const state = yield* stateFor(accountKey); - return yield* state.lock.withPermits(1)( - Effect.gen(function* () { - const existing = yield* Ref.get(state.pendingKey); - const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4()); - yield* Ref.set(state.pendingKey, idempotencyKey); - const outcome = yield* consume(idempotencyKey).pipe( - Effect.tapError((error) => - isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, - ), - ); - yield* Ref.set(state.pendingKey, null); - return outcome; - }), - ); - }); + const redeem: ResetCreditCoordinator["Service"]["redeem"] = Effect.fn( + "resetCreditCoordinator.redeem", + )(function* (accountKey, consume, isSettled) { + const state = yield* stateFor(accountKey); + return yield* state.lock.withPermits(1)( + Effect.gen(function* () { + const existing = yield* Ref.get(state.pendingKey); + const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4()); + yield* Ref.set(state.pendingKey, idempotencyKey); + const outcome = yield* consume(idempotencyKey).pipe( + Effect.tapError((error) => + isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, + ), + ); + yield* Ref.set(state.pendingKey, null); + return outcome; + }), + ); + }); return { redeem } satisfies ResetCreditCoordinator["Service"]; }); diff --git a/apps/server/src/provider/unavailableProviderSnapshot.ts b/apps/server/src/provider/unavailableProviderSnapshot.ts index 368c7e86d460..d1888278ab3c 100644 --- a/apps/server/src/provider/unavailableProviderSnapshot.ts +++ b/apps/server/src/provider/unavailableProviderSnapshot.ts @@ -19,38 +19,36 @@ export interface UnavailableProviderSnapshotInput { const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -export function buildUnavailableProviderSnapshot( - input: UnavailableProviderSnapshotInput, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = input.checkedAt ?? (yield* nowIso); - const displayName = input.displayName?.trim() || (input.driverKind as string); +export const buildUnavailableProviderSnapshot = Effect.fn( + "unavailableProviderSnapshot.buildUnavailableProviderSnapshot", +)(function* (input: UnavailableProviderSnapshotInput): Effect.fn.Return { + const checkedAt = input.checkedAt ?? (yield* nowIso); + const displayName = input.displayName?.trim() || (input.driverKind as string); - const base = buildServerProvider({ - presentation: { displayName }, - enabled: false, - checkedAt, - models: [], - skills: [], - probe: { - installed: false, - version: null, - status: "error", - auth: { status: "unknown" }, - message: input.reason, - }, - }); - - return { - ...base, - instanceId: input.instanceId, - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - driver: - typeof input.driverKind === "string" - ? ProviderDriverKind.make(input.driverKind) - : input.driverKind, - availability: "unavailable", - unavailableReason: input.reason, - }; + const base = buildServerProvider({ + presentation: { displayName }, + enabled: false, + checkedAt, + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: input.reason, + }, }); -} + + return { + ...base, + instanceId: input.instanceId, + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + driver: + typeof input.driverKind === "string" + ? ProviderDriverKind.make(input.driverKind) + : input.driverKind, + availability: "unavailable", + unavailableReason: input.reason, + }; +}); From 236c11962fdca4ae8c742f08e3e76e75ea1d2c39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:30:59 -0700 Subject: [PATCH 006/458] refactor(orchestration): trace storage operations without per-event spans (#21994) --- .../src/orchestration-v2/EffectOutbox.ts | 120 +- apps/server/src/orchestration-v2/EventSink.ts | 20 +- .../src/orchestration-v2/LiveStreamBudget.ts | 72 +- .../src/orchestration-v2/ProjectionStore.ts | 1459 +++++++++-------- .../orchestration-v2/ProviderEventIngestor.ts | 670 ++++---- .../src/orchestration-v2/ShellStream.ts | 40 +- .../ThreadLiveEventCoalescer.ts | 271 +-- .../orchestration-v2/TurnItemPositionStore.ts | 66 +- 8 files changed, 1399 insertions(+), 1319 deletions(-) diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index cb41dc736512..18c2283fb3dc 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -370,8 +370,8 @@ export const layer: Layer.Layer = La ); const service: EffectOutboxV2Shape = { - enqueue: (effects) => - Effect.gen(function* () { + enqueue: Effect.fn("EffectOutbox.enqueue")( + function* (effects) { const now = yield* DateTime.now; const nowIso = DateTime.formatIso(now); yield* Effect.forEach( @@ -406,7 +406,9 @@ export const layer: Layer.Layer = La { concurrency: 1, discard: true }, ); // EventSink owns the corresponding post-commit notification. - }).pipe(Effect.mapError((cause) => new EffectOutboxError({ operation: "enqueue", cause }))), + }, + Effect.mapError((cause) => new EffectOutboxError({ operation: "enqueue", cause })), + ), get: (effectId) => sql` SELECT * @@ -438,8 +440,8 @@ export const layer: Layer.Layer = La : new EffectOutboxError({ operation: "list", cause }), ), ), - cancelUnsettled: ({ threadId, effectTypes, reason }) => - Effect.gen(function* () { + cancelUnsettled: Effect.fn("EffectOutbox.cancelUnsettled")( + function* ({ threadId, effectTypes, reason }) { if (effectTypes.length === 0) return []; const now = DateTime.formatIso(yield* DateTime.now); const rows = yield* sql<{ readonly effect_id: string }>` @@ -457,40 +459,36 @@ export const layer: Layer.Layer = La RETURNING effect_id `; return rows.map(({ effect_id }) => effect_id); - }).pipe( - Effect.mapError( - (cause) => new EffectOutboxError({ operation: "cancel-unsettled", cause }), - ), - ), - signalCancellations: (effectIds) => - Effect.gen(function* () { - yield* Effect.forEach( - effectIds, - (effectId) => { - const signal = cancellationSignals.get(effectId); - return signal === undefined ? Effect.void : Deferred.succeed(signal, undefined); - }, - { discard: true }, - ); - yield* signalSettled(effectIds); - if (effectIds.length > 0) yield* notifyAvailable(effectIds.length); - }), + }, + Effect.mapError((cause) => new EffectOutboxError({ operation: "cancel-unsettled", cause })), + ), + signalCancellations: Effect.fn("EffectOutbox.signalCancellations")(function* (effectIds) { + yield* Effect.forEach( + effectIds, + (effectId) => { + const signal = cancellationSignals.get(effectId); + return signal === undefined ? Effect.void : Deferred.succeed(signal, undefined); + }, + { discard: true }, + ); + yield* signalSettled(effectIds); + if (effectIds.length > 0) yield* notifyAvailable(effectIds.length); + }), awaitCancellation: (effectId) => Deferred.await(cancellationSignal(effectId)), clearCancellation: (effectId) => Effect.sync(() => { cancellationSignals.delete(effectId); }), - awaitSettled: (effectId) => - Effect.gen(function* () { - // Registered before the read, so a settle right after it still resolves. - const signal = settledSignals.get(effectId) ?? Deferred.makeUnsafe(); - settledSignals.set(effectId, signal); - const effect = yield* service.get(effectId); - if (Option.exists(effect, ({ status }) => status === "pending" || status === "running")) { - return yield* Deferred.await(signal); - } - yield* signalSettled([effectId]); - }), + awaitSettled: Effect.fn("EffectOutbox.awaitSettled")(function* (effectId) { + // Registered before the read, so a settle right after it still resolves. + const signal = settledSignals.get(effectId) ?? Deferred.makeUnsafe(); + settledSignals.set(effectId, signal); + const effect = yield* service.get(effectId); + if (Option.exists(effect, ({ status }) => status === "pending" || status === "running")) { + return yield* Deferred.await(signal); + } + yield* signalSettled([effectId]); + }), reconcileAfterProcessLoss: Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); const cancelledRows = yield* sql<{ readonly effect_id: string }>` @@ -526,8 +524,9 @@ export const layer: Layer.Layer = La (cause) => new EffectOutboxError({ operation: "reconcile-process-loss", cause }), ), ), - claimNext: ({ workerId, leaseDurationMs, excludeRestartContinuations = false }) => - Effect.gen(function* () { + // The worker polls for the next effect even when there is no work to claim. + claimNext: Effect.fnUntraced( + function* ({ workerId, leaseDurationMs, excludeRestartContinuations = false }) { const now = yield* DateTime.now; const nowIso = DateTime.formatIso(now); const leaseExpiresAt = DateTime.formatIso( @@ -560,7 +559,9 @@ export const layer: Layer.Layer = La if (row === undefined) return Option.none(); cancellationSignals.set(row.effect_id, Deferred.makeUnsafe()); return Option.some(yield* rowToEffect(row)); - }).pipe(Effect.mapError((cause) => new EffectOutboxError({ operation: "claim", cause }))), + }, + Effect.mapError((cause) => new EffectOutboxError({ operation: "claim", cause })), + ), nextClaimableAt: Effect.gen(function* () { const rows = yield* sql<{ readonly available_at: string | null }>` SELECT MIN(candidate.available_at) AS available_at @@ -584,8 +585,8 @@ export const layer: Layer.Layer = La : new EffectOutboxError({ operation: "next-claimable", cause }), ), ), - succeed: ({ effectId, workerId }) => - Effect.gen(function* () { + succeed: Effect.fn("EffectOutbox.succeed")( + function* ({ effectId, workerId }) { const now = DateTime.formatIso(yield* DateTime.now); const rows = yield* sql<{ readonly effect_id: string }>` UPDATE orchestration_v2_effect_outbox @@ -607,13 +608,16 @@ export const layer: Layer.Layer = La yield* notifyAvailable(); } return rows.length === 1; - }).pipe( - Effect.mapError( - (cause) => new EffectOutboxError({ operation: "succeed", effectId, cause }), + }, + (effect, { effectId }) => + effect.pipe( + Effect.mapError( + (cause) => new EffectOutboxError({ operation: "succeed", effectId, cause }), + ), ), - ), - retry: ({ effectId, workerId, error, delayMs }) => - Effect.gen(function* () { + ), + retry: Effect.fn("EffectOutbox.retry")( + function* ({ effectId, workerId, error, delayMs }) { const now = yield* DateTime.now; const nowIso = DateTime.formatIso(now); const availableAt = DateTime.formatIso( @@ -638,13 +642,16 @@ export const layer: Layer.Layer = La yield* notifyAvailable(); } return rows.length === 1; - }).pipe( - Effect.mapError( - (cause) => new EffectOutboxError({ operation: "retry", effectId, cause }), + }, + (effect, { effectId }) => + effect.pipe( + Effect.mapError( + (cause) => new EffectOutboxError({ operation: "retry", effectId, cause }), + ), ), - ), - fail: ({ effectId, workerId, error }) => - Effect.gen(function* () { + ), + fail: Effect.fn("EffectOutbox.fail")( + function* ({ effectId, workerId, error }) { const now = DateTime.formatIso(yield* DateTime.now); const rows = yield* sql<{ readonly effect_id: string }>` UPDATE orchestration_v2_effect_outbox @@ -666,9 +673,14 @@ export const layer: Layer.Layer = La yield* notifyAvailable(); } return rows.length === 1; - }).pipe( - Effect.mapError((cause) => new EffectOutboxError({ operation: "fail", effectId, cause })), - ), + }, + (effect, { effectId }) => + effect.pipe( + Effect.mapError( + (cause) => new EffectOutboxError({ operation: "fail", effectId, cause }), + ), + ), + ), pruneSettled: Effect.gen(function* () { const cutoff = DateTime.formatIso( DateTime.subtractDuration(yield* DateTime.now, SETTLED_EFFECT_RETENTION), diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 13916b40f9de..6294638a9b07 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -227,8 +227,9 @@ const layerBase: Layer.Layer< OrchestrationV2DomainEvent["type"], PubSub.PubSub >(); - const publishLiveEvents = (events: ReadonlyArray) => - Effect.gen(function* () { + const publishLiveEvents = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (events: ReadonlyArray) { yield* PubSub.publishAll(liveEvents, events); for (const [type, pubsub] of liveEventsByType) { yield* PubSub.publishAll( @@ -271,8 +272,9 @@ const layerBase: Layer.Layer< // A user can answer after terminal normalization reads the pending request. // Recheck inside the write transaction so stale cleanup cannot erase answers. - const guardUserInputCancellations = (events: ReadonlyArray) => - Effect.gen(function* () { + const guardUserInputCancellations = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (events: ReadonlyArray) { const staleRequests = new Set(); const staleNodes = new Set(); for (const event of events) { @@ -337,8 +339,9 @@ const layerBase: Layer.Layer< ); }; - const applyStoredEvents = (storedEvents: ReadonlyArray) => - Effect.gen(function* () { + const applyStoredEvents = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (storedEvents: ReadonlyArray) { yield* Effect.forEach(storedEvents, (stored) => projectionStore.apply(stored.event), { concurrency: 1, }); @@ -564,8 +567,9 @@ const layerBase: Layer.Layer< ); }); - const existingCommandResult = (commandId: CommandId) => - Effect.gen(function* () { + const existingCommandResult = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (commandId: CommandId) { const existing = yield* commandReceipts.getByCommandId(commandId); if (Option.isNone(existing)) { return yield* Effect.die( diff --git a/apps/server/src/orchestration-v2/LiveStreamBudget.ts b/apps/server/src/orchestration-v2/LiveStreamBudget.ts index c7133e41abc4..d70483839986 100644 --- a/apps/server/src/orchestration-v2/LiveStreamBudget.ts +++ b/apps/server/src/orchestration-v2/LiveStreamBudget.ts @@ -207,45 +207,41 @@ type LiveStreamLimits = { readonly maxSerializedBytes?: number; }; -const retainLiveStream = ( - source: Stream.Stream, - budget: LiveStreamBudget, -) => - Effect.gen(function* () { - const queue = yield* Queue.unbounded< - RetainedLiveItem, - E | LiveStreamBufferError | Cause.Done - >(); - let closed = false; - const close = (error?: LiveStreamBufferError) => - Effect.gen(function* () { - if (closed) return; - closed = true; - budget.release(yield* Queue.clear(queue).pipe(Effect.orElseSucceed(() => []))); - if (error) yield* Queue.fail(queue, error); - yield* Queue.shutdown(queue); - }); - yield* Effect.addFinalizer(() => close()); - yield* budget.failed.pipe( - Effect.catchTags({ LiveStreamBufferError: close }), - Effect.forkScoped, - ); - yield* source.pipe( - Stream.runForEach((value) => - budget.retain(value).pipe( - Effect.flatMap((item) => Queue.offer(queue, item)), - Effect.uninterruptible, - ), - ), - Effect.raceFirst(budget.failed), - Effect.exit, - Effect.flatMap((exit) => - Exit.isFailure(exit) ? Queue.failCause(queue, exit.cause) : Queue.end(queue), - ), - Effect.forkScoped({ startImmediately: true }), - ); - return Stream.fromQueue(queue); +const retainLiveStream = Effect.fn("LiveStreamBudget.retainLiveStream")(function* < + A extends object, + E, + R, +>(source: Stream.Stream, budget: LiveStreamBudget) { + const queue = yield* Queue.unbounded< + RetainedLiveItem, + E | LiveStreamBufferError | Cause.Done + >(); + let closed = false; + const close = Effect.fn("LiveStreamBudget.close")(function* (error?: LiveStreamBufferError) { + if (closed) return; + closed = true; + budget.release(yield* Queue.clear(queue).pipe(Effect.orElseSucceed(() => []))); + if (error) yield* Queue.fail(queue, error); + yield* Queue.shutdown(queue); }); + yield* Effect.addFinalizer(() => close()); + yield* budget.failed.pipe(Effect.catchTags({ LiveStreamBufferError: close }), Effect.forkScoped); + yield* source.pipe( + Stream.runForEach((value) => + budget.retain(value).pipe( + Effect.flatMap((item) => Queue.offer(queue, item)), + Effect.uninterruptible, + ), + ), + Effect.raceFirst(budget.failed), + Effect.exit, + Effect.flatMap((exit) => + Exit.isFailure(exit) ? Queue.failCause(queue, exit.cause) : Queue.end(queue), + ), + Effect.forkScoped({ startImmediately: true }), + ); + return Stream.fromQueue(queue); +}); export const bufferLiveStream = ( source: Stream.Stream, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2aaa38751710..f29472aaad8f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1886,42 +1886,44 @@ export const layer: Layer.Layer = END `; - const apply: ProjectionStoreV2Shape["apply"] = (event) => - Effect.gen(function* () { - switch (event.type) { - case "thread.pull-request-link-updated": { - const thread = yield* getThread(event.threadId); - yield* apply({ - ...event, - type: "thread.pull-request-synced", - payload: applyThreadPullRequestLinkUpdate(thread, event.payload), - }); - break; - } - case "thread.created": - case "thread.archived": - case "thread.unarchived": - case "thread.deleted": - case "thread.settled": - case "thread.unsettled": - case "thread.snoozed": - case "thread.unsnoozed": - case "thread.auto-settle-set": - case "thread.pinned": - case "thread.unpinned": - case "thread.pin-reordered": - case "thread.active-reordered": - case "thread.visited": - case "thread.marked-unread": - case "thread.metadata-updated": - case "thread.pull-request-synced": - case "thread.runtime-mode-updated": - case "thread.interaction-mode-updated": - case "thread.model-selection-updated": - case "thread.provider-switched": { - const payloadJson = yield* encodeThreadPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + const apply: ProjectionStoreV2Shape["apply"] = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced( + function* (event) { + switch (event.type) { + case "thread.pull-request-link-updated": { + const thread = yield* getThread(event.threadId); + yield* apply({ + ...event, + type: "thread.pull-request-synced", + payload: applyThreadPullRequestLinkUpdate(thread, event.payload), + }); + break; + } + case "thread.created": + case "thread.archived": + case "thread.unarchived": + case "thread.deleted": + case "thread.settled": + case "thread.unsettled": + case "thread.snoozed": + case "thread.unsnoozed": + case "thread.auto-settle-set": + case "thread.pinned": + case "thread.unpinned": + case "thread.pin-reordered": + case "thread.active-reordered": + case "thread.visited": + case "thread.marked-unread": + case "thread.metadata-updated": + case "thread.pull-request-synced": + case "thread.runtime-mode-updated": + case "thread.interaction-mode-updated": + case "thread.model-selection-updated": + case "thread.provider-switched": { + const payloadJson = yield* encodeThreadPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_threads ( thread_id, project_id, @@ -1967,13 +1969,13 @@ export const layer: Layer.Layer = deleted_at = excluded.deleted_at, payload_json = excluded.payload_json `; - break; - } - case "run.created": - case "run.updated": { - const payloadJson = yield* encodeRunPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "run.created": + case "run.updated": { + const payloadJson = yield* encodeRunPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_runs ( run_id, thread_id, @@ -2013,14 +2015,14 @@ export const layer: Layer.Layer = "$.restartCancelledBackgroundWork", )} `; - break; - } - case "run.background-work-cancelled": { - // Only this field changes, so a concurrent lifecycle write is never regressed. - const workJson = yield* encodeRestartCancelledBackgroundWork( - event.payload.restartCancelledBackgroundWork, - ); - yield* sql` + break; + } + case "run.background-work-cancelled": { + // Only this field changes, so a concurrent lifecycle write is never regressed. + const workJson = yield* encodeRestartCancelledBackgroundWork( + event.payload.restartCancelledBackgroundWork, + ); + yield* sql` UPDATE orchestration_v2_projection_runs SET payload_json = json_set( payload_json, @@ -2029,12 +2031,12 @@ export const layer: Layer.Layer = ) WHERE run_id = ${event.payload.runId} AND thread_id = ${event.threadId} `; - break; - } - case "run-attempt.created": - case "run-attempt.updated": { - const payloadJson = yield* encodeRunAttemptPayload(event.payload); - yield* sql` + break; + } + case "run-attempt.created": + case "run-attempt.updated": { + const payloadJson = yield* encodeRunAttemptPayload(event.payload); + yield* sql` INSERT INTO orchestration_v2_projection_run_attempts ( attempt_id, thread_id, @@ -2074,12 +2076,12 @@ export const layer: Layer.Layer = status = excluded.status, payload_json = excluded.payload_json `; - break; - } - case "node.updated": { - const payloadJson = yield* encodeNodePayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "node.updated": { + const payloadJson = yield* encodeNodePayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_nodes ( node_id, thread_id, @@ -2128,12 +2130,12 @@ export const layer: Layer.Layer = completed_at = excluded.completed_at, payload_json = excluded.payload_json `; - break; - } - case "subagent.updated": { - const payloadJson = yield* encodeSubagentPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "subagent.updated": { + const payloadJson = yield* encodeSubagentPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_subagents ( subagent_id, thread_id, @@ -2197,13 +2199,13 @@ export const layer: Layer.Layer = ELSE excluded.payload_json END `; - break; - } - case "provider-session.attached": - case "provider-session.updated": { - const payloadJson = yield* encodeProviderSessionPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "provider-session.attached": + case "provider-session.updated": { + const payloadJson = yield* encodeProviderSessionPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_provider_sessions ( provider_session_id, thread_id, @@ -2237,29 +2239,29 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, payload_json = excluded.payload_json `; - if (event.type === "provider-session.attached") { - yield* sql` + if (event.type === "provider-session.attached") { + yield* sql` INSERT OR IGNORE INTO orchestration_v2_projection_provider_session_bindings ( provider_session_id, thread_id ) VALUES (${event.payload.id}, ${event.threadId}) `; + } + break; } - break; - } - case "provider-session.detached": { - yield* sql` + case "provider-session.detached": { + yield* sql` DELETE FROM orchestration_v2_projection_provider_session_bindings WHERE provider_session_id = ${event.payload.providerSessionId} AND thread_id = ${event.threadId} `; - break; - } - case "provider-thread.updated": { - const payloadJson = yield* encodeProviderThreadPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "provider-thread.updated": { + const payloadJson = yield* encodeProviderThreadPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_provider_threads ( provider_thread_id, thread_id, @@ -2302,26 +2304,26 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, payload_json = excluded.payload_json `; - if ( - event.payload.appThreadId !== null && - !isQueuedProviderThreadPlaceholder(event.payload) - ) { - const threadRows = yield* sql` + if ( + event.payload.appThreadId !== null && + !isQueuedProviderThreadPlaceholder(event.payload) + ) { + const threadRows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = ${event.payload.appThreadId} LIMIT 1 `; - const threadRow = threadRows[0]; - if (threadRow !== undefined) { - const thread = yield* decodeThreadPayload(threadRow.payload_json); - const updatedThread = { - ...thread, - activeProviderThreadId: event.payload.id, - updatedAt: event.payload.updatedAt, - }; - const updatedThreadPayloadJson = yield* encodeThreadPayload(updatedThread); - yield* sql` + const threadRow = threadRows[0]; + if (threadRow !== undefined) { + const thread = yield* decodeThreadPayload(threadRow.payload_json); + const updatedThread = { + ...thread, + activeProviderThreadId: event.payload.id, + updatedAt: event.payload.updatedAt, + }; + const updatedThreadPayloadJson = yield* encodeThreadPayload(updatedThread); + yield* sql` UPDATE orchestration_v2_projection_threads SET active_provider_thread_id = ${event.payload.id}, @@ -2329,31 +2331,31 @@ export const layer: Layer.Layer = payload_json = ${updatedThreadPayloadJson} WHERE thread_id = ${event.payload.appThreadId} `; + } } + break; } - break; - } - case "provider-turn.updated": { - const existingRows = - event.payload.tokenUsage === undefined - ? yield* sql` + case "provider-turn.updated": { + const existingRows = + event.payload.tokenUsage === undefined + ? yield* sql` SELECT payload_json FROM orchestration_v2_projection_provider_turns WHERE provider_turn_id = ${event.payload.id} LIMIT 1 ` - : []; - const existing = existingRows[0]; - const providerTurn = - existing === undefined - ? event.payload - : upsertProviderTurn( - [yield* decodeProviderTurnPayload(existing.payload_json)], - event.payload, - )[0]!; - const payloadJson = yield* encodeProviderTurnPayload(providerTurn); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + : []; + const existing = existingRows[0]; + const providerTurn = + existing === undefined + ? event.payload + : upsertProviderTurn( + [yield* decodeProviderTurnPayload(existing.payload_json)], + event.payload, + )[0]!; + const payloadJson = yield* encodeProviderTurnPayload(providerTurn); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_provider_turns ( provider_turn_id, thread_id, @@ -2390,12 +2392,12 @@ export const layer: Layer.Layer = completed_at = excluded.completed_at, payload_json = excluded.payload_json `; - break; - } - case "runtime-request.updated": { - const payloadJson = yield* encodeRuntimeRequestPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "runtime-request.updated": { + const payloadJson = yield* encodeRuntimeRequestPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_runtime_requests ( runtime_request_id, thread_id, @@ -2429,12 +2431,12 @@ export const layer: Layer.Layer = resolved_at = excluded.resolved_at, payload_json = excluded.payload_json `; - break; - } - case "message.updated": { - const payloadJson = yield* encodeMessagePayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "message.updated": { + const payloadJson = yield* encodeMessagePayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_messages ( message_id, thread_id, @@ -2468,11 +2470,11 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, payload_json = excluded.payload_json `; - break; - } - case "plan.updated": { - const payloadJson = yield* encodePlanPayload(event.payload); - yield* sql` + break; + } + case "plan.updated": { + const payloadJson = yield* encodePlanPayload(event.payload); + yield* sql` INSERT INTO orchestration_v2_projection_plans ( plan_id, thread_id, @@ -2500,12 +2502,12 @@ export const layer: Layer.Layer = status = excluded.status, payload_json = excluded.payload_json `; - break; - } - case "turn-item.updated": { - const payloadJson = yield* encodeTurnItemPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "turn-item.updated": { + const payloadJson = yield* encodeTurnItemPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_turn_items ( turn_item_id, thread_id, @@ -2548,12 +2550,12 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, payload_json = excluded.payload_json `; - break; - } - case "checkpoint-scope.created": { - const payloadJson = yield* encodeCheckpointScopePayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "checkpoint-scope.created": { + const payloadJson = yield* encodeCheckpointScopePayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_checkpoint_scopes ( scope_id, thread_id, @@ -2593,12 +2595,12 @@ export const layer: Layer.Layer = created_at = excluded.created_at, payload_json = excluded.payload_json `; - break; - } - case "checkpoint.captured": { - const payloadJson = yield* encodeCheckpointPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "checkpoint.captured": { + const payloadJson = yield* encodeCheckpointPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_checkpoints ( checkpoint_id, thread_id, @@ -2638,14 +2640,14 @@ export const layer: Layer.Layer = captured_at = excluded.captured_at, payload_json = excluded.payload_json `; - break; - } - case "checkpoint.rollback-requested": - break; - case "context-handoff.updated": { - const payloadJson = yield* encodeContextHandoffPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "checkpoint.rollback-requested": + break; + case "context-handoff.updated": { + const payloadJson = yield* encodeContextHandoffPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_context_handoffs ( context_handoff_id, thread_id, @@ -2676,13 +2678,13 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, payload_json = excluded.payload_json `; - break; - } - case "context-transfer.created": - case "context-transfer.updated": { - const payloadJson = yield* encodeContextTransferPayload(event.payload); - const payload = parseEncodedPayload(payloadJson); - yield* sql` + break; + } + case "context-transfer.created": + case "context-transfer.updated": { + const payloadJson = yield* encodeContextTransferPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` INSERT INTO orchestration_v2_projection_context_transfers ( context_transfer_id, source_thread_id, @@ -2725,66 +2727,69 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, payload_json = excluded.payload_json `; - break; + break; + } } - } - if ( - event.type !== "thread.created" && - event.type !== "thread.archived" && - event.type !== "thread.unarchived" && - event.type !== "thread.deleted" && - event.type !== "thread.settled" && - event.type !== "thread.unsettled" && - event.type !== "thread.snoozed" && - event.type !== "thread.unsnoozed" && - event.type !== "thread.auto-settle-set" && - event.type !== "thread.pinned" && - event.type !== "thread.unpinned" && - event.type !== "thread.pin-reordered" && - event.type !== "thread.active-reordered" && - event.type !== "thread.visited" && - event.type !== "thread.marked-unread" && - event.type !== "thread.metadata-updated" && - event.type !== "thread.pull-request-synced" && - event.type !== "thread.pull-request-link-updated" && - event.type !== "thread.runtime-mode-updated" && - event.type !== "thread.interaction-mode-updated" && - event.type !== "thread.model-selection-updated" && - event.type !== "thread.provider-switched" - ) { - const updatedAt = yield* encodeThreadUpdatedAt(event.occurredAt); - yield* sql` + if ( + event.type !== "thread.created" && + event.type !== "thread.archived" && + event.type !== "thread.unarchived" && + event.type !== "thread.deleted" && + event.type !== "thread.settled" && + event.type !== "thread.unsettled" && + event.type !== "thread.snoozed" && + event.type !== "thread.unsnoozed" && + event.type !== "thread.auto-settle-set" && + event.type !== "thread.pinned" && + event.type !== "thread.unpinned" && + event.type !== "thread.pin-reordered" && + event.type !== "thread.active-reordered" && + event.type !== "thread.visited" && + event.type !== "thread.marked-unread" && + event.type !== "thread.metadata-updated" && + event.type !== "thread.pull-request-synced" && + event.type !== "thread.pull-request-link-updated" && + event.type !== "thread.runtime-mode-updated" && + event.type !== "thread.interaction-mode-updated" && + event.type !== "thread.model-selection-updated" && + event.type !== "thread.provider-switched" + ) { + const updatedAt = yield* encodeThreadUpdatedAt(event.occurredAt); + yield* sql` UPDATE orchestration_v2_projection_threads SET updated_at = ${updatedAt}, payload_json = json_set(payload_json, '$.updatedAt', ${updatedAt}) WHERE thread_id = ${event.threadId} `; - } - }).pipe( - Effect.mapError( - (cause) => - new ProjectionStoreApplyEventError({ - eventType: event.type, - cause, - }), - ), + } + }, + (effect, event) => + effect.pipe( + Effect.mapError( + (cause) => + new ProjectionStoreApplyEventError({ + eventType: event.type, + cause, + }), + ), + ), ); - const readCanonicalProjection = ( - threadId: ThreadId, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - }, - fields?: ReadonlyArray, - filter?: ProjectionRecordFilter, - ) => - Effect.gen(function* () { + const readCanonicalProjection = Effect.fn("ProjectionStore.readCanonicalProjection")( + function* ( + threadId: ThreadId, + window?: { + readonly rowLimit: number; + readonly userTurnLimit?: number | undefined; + readonly anchorItemId?: TurnItemId | undefined; + readonly requiredRunId?: RunId | undefined; + readonly suppressLocal?: boolean | undefined; + }, + fields?: ReadonlyArray, + filter?: ProjectionRecordFilter, + ) { const threadRows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads @@ -3425,18 +3430,21 @@ export const layer: Layer.Layer = updatedAt: thread.updatedAt, } satisfies OrchestrationV2ThreadProjection; return projection; - }).pipe( - Effect.mapError((cause) => - isProjectionStoreThreadNotFoundError(cause) - ? cause - : new ProjectionStoreReadError({ - threadId, - cause, - }), + }, + (effect, threadId, _window?, _fields?, _filter?) => + effect.pipe( + Effect.mapError((cause) => + isProjectionStoreThreadNotFoundError(cause) + ? cause + : new ProjectionStoreReadError({ + threadId, + cause, + }), + ), ), - ); + ); - const readProjection = ( + const readProjection: ( threadId: ThreadId, seenThreadIds: ReadonlySet, window?: { @@ -3449,8 +3457,23 @@ export const layer: Layer.Layer = | { readonly threadId: ThreadId; readonly itemId: TurnItemId } | undefined; }, - ): Effect.Effect => - Effect.gen(function* () { + ) => Effect.Effect = Effect.fn( + "ProjectionStore.readProjection", + )( + function* ( + threadId: ThreadId, + seenThreadIds: ReadonlySet, + window?: { + readonly rowLimit: number; + readonly userTurnLimit?: number | undefined; + readonly anchorItemId?: TurnItemId | undefined; + readonly requiredRunId?: RunId | undefined; + readonly suppressLocal?: boolean | undefined; + readonly historyAnchor?: + | { readonly threadId: ThreadId; readonly itemId: TurnItemId } + | undefined; + }, + ) { const localWindow = window?.suppressLocal === true || (window?.historyAnchor !== undefined && window.historyAnchor.threadId !== threadId) @@ -3579,13 +3602,16 @@ export const layer: Layer.Layer = sourceProjection, }), }; - }).pipe( - Effect.mapError((cause) => - isProjectionStoreThreadNotFoundError(cause) || isProjectionStoreReadError(cause) - ? cause - : new ProjectionStoreReadError({ threadId, cause }), + }, + (effect, threadId, _seenThreadIds, _window?) => + effect.pipe( + Effect.mapError((cause) => + isProjectionStoreThreadNotFoundError(cause) || isProjectionStoreReadError(cause) + ? cause + : new ProjectionStoreReadError({ threadId, cause }), + ), ), - ); + ); const getLimitRecoveryCandidates = Effect.fn("ProjectionStore.getLimitRecoveryCandidates")( function* (options: Parameters[0]) { @@ -4106,8 +4132,10 @@ export const layer: Layer.Layer = ), ); - const getTurnStartHistory: ProjectionStoreV2Shape["getTurnStartHistory"] = (threadId, runIds) => - Effect.gen(function* () { + const getTurnStartHistory: ProjectionStoreV2Shape["getTurnStartHistory"] = Effect.fn( + "ProjectionStore.getTurnStartHistory", + )( + function* (threadId, runIds) { const rows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} @@ -4117,37 +4145,39 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC `; return yield* decodeRows(decodeTurnItemPayload, threadId)(rows); - }).pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause }))); + }, + (effect, threadId, _runIds) => + effect.pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause }))), + ); const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) => readProjection(threadId, new Set()); - const getRuntimeRecoveryProjection: ProjectionStoreV2Shape["getRuntimeRecoveryProjection"] = ( - threadId, - ) => - Effect.gen(function* () { - const threadRows = yield* sql` + const getRuntimeRecoveryProjection: ProjectionStoreV2Shape["getRuntimeRecoveryProjection"] = + Effect.fn("ProjectionStore.getRuntimeRecoveryProjection")( + function* (threadId) { + const threadRows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} `; - if (threadRows[0] === undefined) { - return yield* new ProjectionStoreThreadNotFoundError({ threadId }); - } - const [ - thread, - runRows, - attemptRows, - nodeRows, - subagentRows, - providerSessionRows, - providerThreadRows, - providerTurnRows, - runtimeRequestRows, - messageRows, - turnItemRows, - ] = yield* Effect.all([ - decodeThreadPayload(threadRows[0].payload_json), - sql` + if (threadRows[0] === undefined) { + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + } + const [ + thread, + runRows, + attemptRows, + nodeRows, + subagentRows, + providerSessionRows, + providerThreadRows, + providerTurnRows, + runtimeRequestRows, + messageRows, + turnItemRows, + ] = yield* Effect.all([ + decodeThreadPayload(threadRows[0].payload_json), + sql` SELECT payload_json FROM orchestration_v2_projection_runs AS run WHERE run.thread_id = ${threadId} AND ( @@ -4190,7 +4220,7 @@ export const layer: Layer.Layer = ) ORDER BY run.ordinal ASC `, - sql` + sql` SELECT attempt.payload_json FROM orchestration_v2_projection_run_attempts AS attempt WHERE attempt.thread_id = ${threadId} @@ -4201,7 +4231,7 @@ export const layer: Layer.Layer = ) ORDER BY attempt.run_id ASC, attempt.attempt_ordinal ASC `, - sql` + sql` SELECT node.payload_json FROM orchestration_v2_projection_nodes AS node WHERE node.thread_id = ${threadId} AND node.status IN ('pending', 'starting', 'running', 'waiting') @@ -4232,7 +4262,7 @@ export const layer: Layer.Layer = ) ORDER BY COALESCE(node.started_at, ''), node.node_id ASC `, - sql` + sql` SELECT subagent.payload_json FROM orchestration_v2_projection_subagents AS subagent WHERE subagent.thread_id = ${threadId} AND subagent.status IN ('pending', 'starting', 'running', 'waiting') @@ -4252,7 +4282,7 @@ export const layer: Layer.Layer = ) ORDER BY COALESCE(subagent.started_at, ''), subagent.subagent_id ASC `, - sql` + sql` SELECT session.payload_json FROM orchestration_v2_projection_provider_sessions AS session WHERE ( @@ -4287,7 +4317,7 @@ export const layer: Layer.Layer = ) ORDER BY session.updated_at ASC, session.provider_session_id ASC `, - sql` + sql` SELECT provider_thread.payload_json FROM orchestration_v2_projection_provider_threads AS provider_thread WHERE ( @@ -4331,7 +4361,7 @@ export const layer: Layer.Layer = ) ORDER BY COALESCE(provider_thread.first_run_ordinal, 0), provider_thread.provider_thread_id ASC `, - sql` + sql` SELECT provider_turn.payload_json FROM orchestration_v2_projection_provider_turns AS provider_turn WHERE provider_turn.thread_id = ${threadId} @@ -4347,12 +4377,12 @@ export const layer: Layer.Layer = ) ORDER BY provider_turn.provider_thread_id ASC, provider_turn.ordinal ASC `, - sql` + sql` SELECT payload_json FROM orchestration_v2_projection_runtime_requests WHERE thread_id = ${threadId} AND status = 'pending' ORDER BY created_at ASC, runtime_request_id ASC `, - sql` + sql` SELECT message.payload_json FROM orchestration_v2_projection_messages AS message WHERE message.thread_id = ${threadId} AND message.streaming = 1 AND message.run_id IN ( @@ -4362,7 +4392,7 @@ export const layer: Layer.Layer = ) ORDER BY message.created_at ASC, message.message_id ASC `, - sql` + sql` SELECT item.payload_json FROM orchestration_v2_projection_turn_items AS item WHERE item.thread_id = ${threadId} AND item.status IN ('pending', 'running', 'waiting') @@ -4386,53 +4416,56 @@ export const layer: Layer.Layer = ) ORDER BY item.ordinal ASC, item.turn_item_id ASC `, - ]); - const [ - runs, - attempts, - nodes, - subagents, - providerSessions, - providerThreads, - providerTurns, - runtimeRequests, - messages, - turnItems, - ] = yield* Effect.all([ - decodeRows(decodeRunPayload, threadId)(runRows), - decodeRows(decodeRunAttemptPayload, threadId)(attemptRows), - decodeRows(decodeNodePayload, threadId)(nodeRows), - decodeRows(decodeSubagentPayload, threadId)(subagentRows), - decodeRows(decodeProviderSessionPayload, threadId)(providerSessionRows), - decodeRows(decodeProviderThreadPayload, threadId)(providerThreadRows), - decodeRows(decodeProviderTurnPayload, threadId)(providerTurnRows), - decodeRows(decodeRuntimeRequestPayload, threadId)(runtimeRequestRows), - decodeRows(decodeMessagePayload, threadId)(messageRows), - decodeRows(decodeTurnItemPayload, threadId)(turnItemRows), - ]); - return { - thread, - runs, - attempts, - nodes, - subagents, - providerSessions, - providerThreads, - providerTurns, - runtimeRequests, - messages, - turnItems, - } satisfies ProjectionRuntimeRecoveryState; - }).pipe( - Effect.mapError((cause) => - isProjectionStoreThreadNotFoundError(cause) - ? cause - : new ProjectionStoreReadError({ threadId, cause }), - ), + ]); + const [ + runs, + attempts, + nodes, + subagents, + providerSessions, + providerThreads, + providerTurns, + runtimeRequests, + messages, + turnItems, + ] = yield* Effect.all([ + decodeRows(decodeRunPayload, threadId)(runRows), + decodeRows(decodeRunAttemptPayload, threadId)(attemptRows), + decodeRows(decodeNodePayload, threadId)(nodeRows), + decodeRows(decodeSubagentPayload, threadId)(subagentRows), + decodeRows(decodeProviderSessionPayload, threadId)(providerSessionRows), + decodeRows(decodeProviderThreadPayload, threadId)(providerThreadRows), + decodeRows(decodeProviderTurnPayload, threadId)(providerTurnRows), + decodeRows(decodeRuntimeRequestPayload, threadId)(runtimeRequestRows), + decodeRows(decodeMessagePayload, threadId)(messageRows), + decodeRows(decodeTurnItemPayload, threadId)(turnItemRows), + ]); + return { + thread, + runs, + attempts, + nodes, + subagents, + providerSessions, + providerThreads, + providerTurns, + runtimeRequests, + messages, + turnItems, + } satisfies ProjectionRuntimeRecoveryState; + }, + (effect, threadId) => + effect.pipe( + Effect.mapError((cause) => + isProjectionStoreThreadNotFoundError(cause) + ? cause + : new ProjectionStoreReadError({ threadId, cause }), + ), + ), ); - const getThread: ProjectionStoreV2Shape["getThread"] = (threadId) => - Effect.gen(function* () { + const getThread: ProjectionStoreV2Shape["getThread"] = Effect.fn("ProjectionStore.getThread")( + function* (threadId) { const rows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} @@ -4441,21 +4474,25 @@ export const layer: Layer.Layer = return yield* new ProjectionStoreThreadNotFoundError({ threadId }); } return yield* decodeThreadPayload(rows[0].payload_json); - }).pipe( - Effect.mapError((cause) => - isProjectionStoreThreadNotFoundError(cause) - ? cause - : new ProjectionStoreReadError({ threadId, cause }), + }, + (effect, threadId) => + effect.pipe( + Effect.mapError((cause) => + isProjectionStoreThreadNotFoundError(cause) + ? cause + : new ProjectionStoreReadError({ threadId, cause }), + ), ), - ); + ); - const requireThread = (threadId: ThreadId) => - Effect.gen(function* () { - const rows = yield* sql<{ readonly thread_id: string }>` + const requireThread = Effect.fn("ProjectionStore.requireThread")(function* ( + threadId: ThreadId, + ) { + const rows = yield* sql<{ readonly thread_id: string }>` SELECT thread_id FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} LIMIT 1 `; - if (rows.length === 0) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); - }); + if (rows.length === 0) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + }); const controlReadError = (threadId: ThreadId) => (cause: unknown) => isProjectionStoreThreadNotFoundError(cause) ? cause @@ -4518,8 +4555,8 @@ export const layer: Layer.Layer = .pipe(Effect.mapError(controlReadError(threadId))); const hasUnpairedRunInterruptRequest: ProjectionStoreV2Shape["hasUnpairedRunInterruptRequest"] = - (threadId, requestId, resultId) => - Effect.gen(function* () { + Effect.fn("ProjectionStore.hasUnpairedRunInterruptRequest")( + function* (threadId, requestId, resultId) { const rows = yield* sql<{ pending: number }>` SELECT EXISTS ( SELECT 1 FROM orchestration_v2_projection_turn_items @@ -4530,16 +4567,23 @@ export const layer: Layer.Layer = ) AS pending `; return rows[0]?.pending === 1; - }).pipe(Effect.mapError(controlReadError(threadId))); + }, + (effect, threadId, _requestId, _resultId) => + effect.pipe(Effect.mapError(controlReadError(threadId))), + ); - const isNodeStored: ProjectionStoreV2Shape["isNodeStored"] = (node) => - Effect.gen(function* () { + const isNodeStored: ProjectionStoreV2Shape["isNodeStored"] = Effect.fn( + "ProjectionStore.isNodeStored", + )( + function* (node) { const payloadJson = yield* encodeNodePayload(node); const rows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_nodes WHERE node_id = ${node.id} `; return rows[0]?.payload_json === payloadJson; - }).pipe(Effect.mapError(controlReadError(node.threadId))); + }, + (effect, node) => effect.pipe(Effect.mapError(controlReadError(node.threadId))), + ); const getRuntimeRequest: ProjectionStoreV2Shape["getRuntimeRequest"] = (threadId, requestId) => sql @@ -5107,18 +5151,18 @@ export const layer: Layer.Layer = readonly item: TimelineIndexItem; readonly synthetic?: OrchestrationV2TurnItem; }; - const readTimelineIndex = ( + const readTimelineIndex: ( threadId: ThreadId, seen: ReadonlySet, - ): Effect.Effect< + ) => Effect.Effect< { readonly records: ProjectionRecords<"runs" | "attempts">; readonly local: ReadonlyArray; readonly visible: ReadonlyArray; }, ProjectionStoreV2Error - > => - Effect.gen(function* () { + > = Effect.fn("ProjectionStore.readTimelineIndex")( + function* (threadId: ThreadId, seen: ReadonlySet) { const records = yield* getThreadRecords(threadId, ["runs", "attempts"]); const rows = yield* sql<{ turn_item_id: string; @@ -5204,13 +5248,16 @@ export const layer: Layer.Layer = ...visible, ], }; - }).pipe( - Effect.mapError((cause) => - isProjectionStoreThreadNotFoundError(cause) || isProjectionStoreReadError(cause) - ? cause - : new ProjectionStoreReadError({ threadId, cause }), + }, + (effect, threadId, _seen) => + effect.pipe( + Effect.mapError((cause) => + isProjectionStoreThreadNotFoundError(cause) || isProjectionStoreReadError(cause) + ? cause + : new ProjectionStoreReadError({ threadId, cause }), + ), ), - ); + ); const { searchThread, searchThreadStream } = yield* makeThreadFind((threadId) => readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)), @@ -5720,47 +5767,46 @@ export const layer: Layer.Layer = return { runOrdinalsByThreadId, itemCountsByThreadId }; }; - const pendingBackgroundDataByThreadId = (input: { + const pendingBackgroundDataByThreadId = Effect.fn( + "ProjectionStore.pendingBackgroundDataByThreadId", + )(function* (input: { readonly providerThreadRows: ReadonlyArray; readonly pendingTurnItemRows: ReadonlyArray; - }) => - Effect.gen(function* () { - const providerThreadsByThreadId = new Map< - ThreadId, - Array - >(); - for (const row of input.providerThreadRows) { - const providerThread = yield* decodeProviderThreadPayload(row.payload_json); - const threadId = - row.thread_id.length > 0 ? ThreadId.make(row.thread_id) : providerThread.appThreadId; - if (threadId === null) { - continue; - } - const existing = providerThreadsByThreadId.get(threadId) ?? []; - existing.push(providerThread); - providerThreadsByThreadId.set(threadId, existing); + }) { + const providerThreadsByThreadId = new Map< + ThreadId, + Array + >(); + for (const row of input.providerThreadRows) { + const providerThread = yield* decodeProviderThreadPayload(row.payload_json); + const threadId = + row.thread_id.length > 0 ? ThreadId.make(row.thread_id) : providerThread.appThreadId; + if (threadId === null) { + continue; } + const existing = providerThreadsByThreadId.get(threadId) ?? []; + existing.push(providerThread); + providerThreadsByThreadId.set(threadId, existing); + } - const pendingTurnItemsByThreadId = new Map>(); - for (const row of input.pendingTurnItemRows) { - const turnItem = { - ...(yield* decodeTurnItemPayload(row.payload_json)), - ...(row.owning_provider_instance_id === null - ? {} - : { - owningProviderInstanceId: ProviderInstanceId.make( - row.owning_provider_instance_id, - ), - }), - }; - const threadId = ThreadId.make(row.thread_id); - const existing = pendingTurnItemsByThreadId.get(threadId) ?? []; - existing.push(turnItem); - pendingTurnItemsByThreadId.set(threadId, existing); - } + const pendingTurnItemsByThreadId = new Map>(); + for (const row of input.pendingTurnItemRows) { + const turnItem = { + ...(yield* decodeTurnItemPayload(row.payload_json)), + ...(row.owning_provider_instance_id === null + ? {} + : { + owningProviderInstanceId: ProviderInstanceId.make(row.owning_provider_instance_id), + }), + }; + const threadId = ThreadId.make(row.thread_id); + const existing = pendingTurnItemsByThreadId.get(threadId) ?? []; + existing.push(turnItem); + pendingTurnItemsByThreadId.set(threadId, existing); + } - return { providerThreadsByThreadId, pendingTurnItemsByThreadId }; - }); + return { providerThreadsByThreadId, pendingTurnItemsByThreadId }; + }); const getSettlementCandidates: ProjectionStoreV2Shape["getSettlementCandidates"] = (threadId) => sql @@ -5874,11 +5920,10 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); - const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = ( - threadId, - ) => - Effect.gen(function* () { - const rows = yield* sql` + const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = + Effect.fn("ProjectionStore.getThreadsWithPullRequests")( + function* (threadId) { + const rows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} @@ -5886,19 +5931,21 @@ export const layer: Layer.Layer = AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC `; - return yield* Effect.forEach(rows, (row) => - decodeThreadPayload(row.payload_json).pipe( - Effect.map((thread): ProjectionThreadPullRequests => ({ - id: thread.id, - projectId: thread.projectId, - lineage: thread.lineage, - settledOverride: thread.settledOverride, - settledAt: thread.settledAt, - pullRequests: thread.pullRequests ?? [], - })), - ), - ); - }).pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); + return yield* Effect.forEach(rows, (row) => + decodeThreadPayload(row.payload_json).pipe( + Effect.map((thread): ProjectionThreadPullRequests => ({ + id: thread.id, + projectId: thread.projectId, + lineage: thread.lineage, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + pullRequests: thread.pullRequests ?? [], + })), + ), + ); + }, + Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), + ); const getThreadWorktreePaths: ProjectionStoreV2Shape["getThreadWorktreePaths"] = (options) => sql<{ readonly thread_id: string; readonly worktree_path: string }>` @@ -5917,20 +5964,21 @@ export const layer: Layer.Layer = Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), ); - const shellThreadStateFromRow = (input: { - readonly row: ShellThreadRow; - readonly runOrdinalsByThreadId: ReadonlyMap>; - readonly itemCountsByThreadId: ReadonlyMap>; - readonly providerThreadsByThreadId: ReadonlyMap< - ThreadId, - ReadonlyArray - >; - readonly pendingTurnItemsByThreadId: ReadonlyMap< - ThreadId, - ReadonlyArray - >; - }) => - Effect.gen(function* () { + const shellThreadStateFromRow = + /* Runs once per shell row; the enclosing snapshot or thread read owns the span. */ + Effect.fnUntraced(function* (input: { + readonly row: ShellThreadRow; + readonly runOrdinalsByThreadId: ReadonlyMap>; + readonly itemCountsByThreadId: ReadonlyMap>; + readonly providerThreadsByThreadId: ReadonlyMap< + ThreadId, + ReadonlyArray + >; + readonly pendingTurnItemsByThreadId: ReadonlyMap< + ThreadId, + ReadonlyArray + >; + }) { const { row, runOrdinalsByThreadId, @@ -6069,62 +6117,61 @@ export const layer: Layer.Layer = const shellSnapshotReadError = (cause: unknown) => new ProjectionStoreReadError({ threadId: ThreadId.make("thread:shell"), cause }); - const readShellSnapshotRows = (options: ShellSnapshotOptions | undefined) => - Effect.gen(function* () { - const targetThreadRows = yield* selectShellThreadRows(undefined, options); - const targetThreadIds = new Set( - targetThreadRows.map((row) => ThreadId.make(row.thread_id)), - ); - const rowsByThreadId = new Map( - targetThreadRows.map((row) => [ThreadId.make(row.thread_id), row] as const), - ); - const pendingSourceIds = targetThreadRows.flatMap((row) => - row.forked_from_run_source_thread_id === null - ? [] - : [ThreadId.make(row.forked_from_run_source_thread_id)], - ); - while (pendingSourceIds.length > 0) { - const sourceId = pendingSourceIds.pop(); - if (sourceId === undefined || rowsByThreadId.has(sourceId)) continue; - const source = (yield* selectShellThreadRows(sourceId))[0]; - if (source === undefined) continue; - rowsByThreadId.set(sourceId, source); - if (source.forked_from_run_source_thread_id !== null) { - pendingSourceIds.push(ThreadId.make(source.forked_from_run_source_thread_id)); - } + const readShellSnapshotRows = Effect.fn("ProjectionStore.readShellSnapshotRows")(function* ( + options: ShellSnapshotOptions | undefined, + ) { + const targetThreadRows = yield* selectShellThreadRows(undefined, options); + const targetThreadIds = new Set(targetThreadRows.map((row) => ThreadId.make(row.thread_id))); + const rowsByThreadId = new Map( + targetThreadRows.map((row) => [ThreadId.make(row.thread_id), row] as const), + ); + const pendingSourceIds = targetThreadRows.flatMap((row) => + row.forked_from_run_source_thread_id === null + ? [] + : [ThreadId.make(row.forked_from_run_source_thread_id)], + ); + while (pendingSourceIds.length > 0) { + const sourceId = pendingSourceIds.pop(); + if (sourceId === undefined || rowsByThreadId.has(sourceId)) continue; + const source = (yield* selectShellThreadRows(sourceId))[0]; + if (source === undefined) continue; + rowsByThreadId.set(sourceId, source); + if (source.forked_from_run_source_thread_id !== null) { + pendingSourceIds.push(ThreadId.make(source.forked_from_run_source_thread_id)); } - const threadRows = [...rowsByThreadId.values()]; - const threadIds = [...rowsByThreadId.keys()]; - const forkSourceIds = shellForkSourceIds(threadRows); - const readForThreadIds = ( - read: (ids: ReadonlyArray) => Effect.Effect, unknown>, - ids: ReadonlyArray = threadIds, - ) => (ids.length === 0 ? Effect.succeed([] as ReadonlyArray) : read(ids)); - const [runRows, itemCountRows, sequenceRows, providerThreadRows, pendingTurnItemRows] = - yield* Effect.all([ - readForThreadIds(selectShellRunRows, forkSourceIds), - readForThreadIds(selectShellRunItemCounts, forkSourceIds), - sql<{ readonly snapshot_sequence: number | null }>` + } + const threadRows = [...rowsByThreadId.values()]; + const threadIds = [...rowsByThreadId.keys()]; + const forkSourceIds = shellForkSourceIds(threadRows); + const readForThreadIds = ( + read: (ids: ReadonlyArray) => Effect.Effect, unknown>, + ids: ReadonlyArray = threadIds, + ) => (ids.length === 0 ? Effect.succeed([] as ReadonlyArray) : read(ids)); + const [runRows, itemCountRows, sequenceRows, providerThreadRows, pendingTurnItemRows] = + yield* Effect.all([ + readForThreadIds(selectShellRunRows, forkSourceIds), + readForThreadIds(selectShellRunItemCounts, forkSourceIds), + sql<{ readonly snapshot_sequence: number | null }>` SELECT MAX(sequence) AS snapshot_sequence FROM orchestration_events WHERE application_event_version = 2 AND aggregate_kind = 'thread' `, - readForThreadIds(selectShellProviderThreadRows), - readForThreadIds(selectShellPendingTurnItemRows), - ]); - return { - targetThreadIds, - threadRows, - runRows, - itemCountRows, - sequenceRows, - providerThreadRows, - pendingTurnItemRows, - }; - }); + readForThreadIds(selectShellProviderThreadRows), + readForThreadIds(selectShellPendingTurnItemRows), + ]); + return { + targetThreadIds, + threadRows, + runRows, + itemCountRows, + sequenceRows, + providerThreadRows, + pendingTurnItemRows, + }; + }); - const decodeShellSnapshot = ({ + const decodeShellSnapshot = Effect.fn("ProjectionStore.decodeShellSnapshot")(function* ({ targetThreadIds, threadRows, runRows, @@ -6132,44 +6179,43 @@ export const layer: Layer.Layer = sequenceRows, providerThreadRows, pendingTurnItemRows, - }: Effect.Success>) => - Effect.gen(function* () { - const { runOrdinalsByThreadId, itemCountsByThreadId } = runMapsByThreadId({ - runRows, - itemCountRows, - }); - const { providerThreadsByThreadId, pendingTurnItemsByThreadId } = - yield* pendingBackgroundDataByThreadId({ providerThreadRows, pendingTurnItemRows }); - const states = yield* Effect.forEach(threadRows, (row) => - shellThreadStateFromRow({ - row, - runOrdinalsByThreadId, - itemCountsByThreadId, - providerThreadsByThreadId, - pendingTurnItemsByThreadId, + }: Effect.Success>) { + const { runOrdinalsByThreadId, itemCountsByThreadId } = runMapsByThreadId({ + runRows, + itemCountRows, + }); + const { providerThreadsByThreadId, pendingTurnItemsByThreadId } = + yield* pendingBackgroundDataByThreadId({ providerThreadRows, pendingTurnItemRows }); + const states = yield* Effect.forEach(threadRows, (row) => + shellThreadStateFromRow({ + row, + runOrdinalsByThreadId, + itemCountsByThreadId, + providerThreadsByThreadId, + pendingTurnItemsByThreadId, + }), + ); + const statesByThreadId = new Map(states.map((state) => [state.thread.id, state])); + + const shells = states + .filter((state) => targetThreadIds.has(state.thread.id)) + .map((state) => + shellFromState({ + state, + visibleItemCount: visibleItemCountForShell({ + threadId: state.thread.id, + statesByThreadId, + }), }), ); - const statesByThreadId = new Map(states.map((state) => [state.thread.id, state])); - const shells = states - .filter((state) => targetThreadIds.has(state.thread.id)) - .map((state) => - shellFromState({ - state, - visibleItemCount: visibleItemCountForShell({ - threadId: state.thread.id, - statesByThreadId, - }), - }), - ); - - return { - schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, - snapshotSequence: sequenceRows[0]?.snapshot_sequence ?? 0, - threads: shells.filter((thread) => thread.archivedAt === null), - archivedThreads: shells.filter((thread) => thread.archivedAt !== null), - }; - }).pipe(Effect.mapError(shellSnapshotReadError)); + return { + schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, + snapshotSequence: sequenceRows[0]?.snapshot_sequence ?? 0, + threads: shells.filter((thread) => thread.archivedAt === null), + archivedThreads: shells.filter((thread) => thread.archivedAt !== null), + }; + }, Effect.mapError(shellSnapshotReadError)); const readShellSnapshot: ProjectionStoreV2Shape["readShellSnapshot"] = (options) => readShellSnapshotRows(options).pipe( @@ -6301,8 +6347,9 @@ export const layerMemory: Layer.Layer = Layer.effect( const sequence = yield* Ref.make(0); const service: ProjectionStoreV2Shape = { - apply: (event) => - Effect.gen(function* () { + apply: + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (event) { const result = yield* Ref.modify(replayState, (existing) => { const next: ProjectionReplayState = { projections: new Map(existing.projections), @@ -6323,65 +6370,62 @@ export const layerMemory: Layer.Layer = Layer.effect( } yield* Ref.update(sequence, (current) => current + 1); }), - getShellSnapshot: (options) => - Effect.gen(function* () { - const existing = (yield* Ref.get(replayState)).projections; - const selectedThreadIds = [...existing.entries()] - .filter(([, projection]) => { - if ( - options?.projectId !== undefined && - projection.thread.projectId !== options.projectId - ) { - return false; - } - if ( - options?.unsettledOnly && - (projection.thread.settledAt !== null || - projection.thread.settledOverride === "settled") - ) { - return false; - } - if (options?.location === "active") return projection.thread.archivedAt === null; - if (options?.location === "archive") return projection.thread.archivedAt !== null; - return true; - }) - .map(([threadId]) => threadId); - const shells = yield* Effect.forEach( - selectedThreadIds.toSorted((left, right) => String(left).localeCompare(String(right))), - (threadId) => - service.getThreadProjection(threadId).pipe(Effect.map(threadShellFromProjection)), - ); - const visible = shells.filter((thread) => thread.deletedAt === null); - return { - schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, - snapshotSequence: yield* Ref.get(sequence), - threads: visible.filter((thread) => thread.archivedAt === null), - archivedThreads: visible.filter((thread) => thread.archivedAt !== null), - }; - }), + getShellSnapshot: Effect.fn("ProjectionStore.getShellSnapshot")(function* (options) { + const existing = (yield* Ref.get(replayState)).projections; + const selectedThreadIds = [...existing.entries()] + .filter(([, projection]) => { + if ( + options?.projectId !== undefined && + projection.thread.projectId !== options.projectId + ) { + return false; + } + if ( + options?.unsettledOnly && + (projection.thread.settledAt !== null || + projection.thread.settledOverride === "settled") + ) { + return false; + } + if (options?.location === "active") return projection.thread.archivedAt === null; + if (options?.location === "archive") return projection.thread.archivedAt !== null; + return true; + }) + .map(([threadId]) => threadId); + const shells = yield* Effect.forEach( + selectedThreadIds.toSorted((left, right) => String(left).localeCompare(String(right))), + (threadId) => + service.getThreadProjection(threadId).pipe(Effect.map(threadShellFromProjection)), + ); + const visible = shells.filter((thread) => thread.deletedAt === null); + return { + schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, + snapshotSequence: yield* Ref.get(sequence), + threads: visible.filter((thread) => thread.archivedAt === null), + archivedThreads: visible.filter((thread) => thread.archivedAt !== null), + }; + }), readShellSnapshot: (options) => service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), - getThreadShell: (threadId) => - Effect.gen(function* () { - const existing = (yield* Ref.get(replayState)).projections; - if (!existing.has(threadId)) { - return null; - } - const shell = yield* service - .getThreadProjection(threadId) - .pipe(Effect.map(threadShellFromProjection)); - return shell.deletedAt === null ? shell : null; - }), - getThread: (threadId) => - Effect.gen(function* () { - const projection = (yield* Ref.get(replayState)).projections.get(threadId); - if (projection === undefined) { - return yield* new ProjectionStoreThreadNotFoundError({ threadId }); - } - return projection.thread; - }), - getSettlementCandidates: (threadId) => - Effect.gen(function* () { + getThreadShell: Effect.fn("ProjectionStore.getThreadShell")(function* (threadId) { + const existing = (yield* Ref.get(replayState)).projections; + if (!existing.has(threadId)) { + return null; + } + const shell = yield* service + .getThreadProjection(threadId) + .pipe(Effect.map(threadShellFromProjection)); + return shell.deletedAt === null ? shell : null; + }), + getThread: Effect.fn("ProjectionStore.getThread")(function* (threadId) { + const projection = (yield* Ref.get(replayState)).projections.get(threadId); + if (projection === undefined) { + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + } + return projection.thread; + }), + getSettlementCandidates: Effect.fn("ProjectionStore.getSettlementCandidates")( + function* (threadId) { const projections = (yield* Ref.get(replayState)).projections; return [...projections.values()] .filter( @@ -6408,7 +6452,8 @@ export const layerMemory: Layer.Layer = Layer.effect( DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) || left.id.localeCompare(right.id), ); - }), + }, + ), getThreadWorktreePaths: (options) => Ref.get(replayState).pipe( Effect.map((state) => @@ -6490,30 +6535,29 @@ export const layerMemory: Layer.Layer = Layer.effect( .toSorted((left, right) => left.id.localeCompare(right.id)), ), ), - getRecoveryThreadIds: (kind) => - Effect.gen(function* () { - const projections = (yield* Ref.get(replayState)).projections; - return [...projections.values()] - .filter((projection) => - needsRecovery( - projection, - kind, - projection.thread.lineage.parentThreadId === null - ? undefined - : projections.get(projection.thread.lineage.parentThreadId), - ), - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(left.thread.updatedAt) - - DateTime.toEpochMillis(right.thread.updatedAt) || - left.thread.id.localeCompare(right.thread.id), - ) - .map((projection) => projection.thread.id); - }), + getRecoveryThreadIds: Effect.fn("ProjectionStore.getRecoveryThreadIds")(function* (kind) { + const projections = (yield* Ref.get(replayState)).projections; + return [...projections.values()] + .filter((projection) => + needsRecovery( + projection, + kind, + projection.thread.lineage.parentThreadId === null + ? undefined + : projections.get(projection.thread.lineage.parentThreadId), + ), + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(left.thread.updatedAt) - + DateTime.toEpochMillis(right.thread.updatedAt) || + left.thread.id.localeCompare(right.thread.id), + ) + .map((projection) => projection.thread.id); + }), getUnreadableThreadIds: () => Effect.succeed([]), - getPendingNativeUserInputs: (threadId, providerTurnId) => - Effect.gen(function* () { + getPendingNativeUserInputs: Effect.fn("ProjectionStore.getPendingNativeUserInputs")( + function* (threadId, providerTurnId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return { runtimeRequests: [], nodes: [], turnItems: [] }; const runtimeRequests = projection.runtimeRequests.filter( @@ -6532,23 +6576,24 @@ export const layerMemory: Layer.Layer = Layer.effect( (item) => item.type === "user_input_request" && requestIds.has(item.requestId), ), }; - }), - getPlan: (threadId, planId) => - Effect.gen(function* () { - const projection = (yield* Ref.get(replayState)).projections.get(threadId); - if (projection === undefined) - return yield* new ProjectionStoreThreadNotFoundError({ threadId }); - return projection.plans.find((plan) => plan.id === planId); - }), - hasUnpairedRunInterruptRequest: (threadId, requestId, resultId) => - Effect.gen(function* () { + }, + ), + getPlan: Effect.fn("ProjectionStore.getPlan")(function* (threadId, planId) { + const projection = (yield* Ref.get(replayState)).projections.get(threadId); + if (projection === undefined) + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + return projection.plans.find((plan) => plan.id === planId); + }), + hasUnpairedRunInterruptRequest: Effect.fn("ProjectionStore.hasUnpairedRunInterruptRequest")( + function* (threadId, requestId, resultId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); return ( projection !== undefined && projection.turnItems.some((item) => item.id === requestId) && !projection.turnItems.some((item) => item.id === resultId) ); - }), + }, + ), isNodeStored: (node) => Ref.get(replayState).pipe( Effect.map((state) => { @@ -6594,8 +6639,8 @@ export const layerMemory: Layer.Layer = Layer.effect( ]), ]), ), - getThreadRecords: (threadId, fields, filter) => - Effect.gen(function* () { + getThreadRecords: Effect.fn("ProjectionStore.getThreadRecords")( + function* (threadId, fields, filter) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); @@ -6630,16 +6675,18 @@ export const layerMemory: Layer.Layer = Layer.effect( ["thread", projection.thread], ...fields.map((field) => [field, selected[field]]), ]) as ProjectionRecords<(typeof fields)[number]>; - }), - getRuntimeRequest: (threadId, requestId) => - Effect.gen(function* () { + }, + ), + getRuntimeRequest: Effect.fn("ProjectionStore.getRuntimeRequest")( + function* (threadId, requestId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); return projection.runtimeRequests.find((request) => request.id === requestId); - }), - getProviderControlContext: (threadId, target) => - Effect.gen(function* () { + }, + ), + getProviderControlContext: Effect.fn("ProjectionStore.getProviderControlContext")( + function* (threadId, target) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); @@ -6664,9 +6711,10 @@ export const layerMemory: Layer.Layer = Layer.effect( ? undefined : projection.runs.find((run) => run.activeAttemptId === providerTurn.runAttemptId), }; - }), - getRunningTurnContext: (threadId) => - Effect.gen(function* () { + }, + ), + getRunningTurnContext: Effect.fn("ProjectionStore.getRunningTurnContext")( + function* (threadId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); @@ -6684,9 +6732,10 @@ export const layerMemory: Layer.Layer = Layer.effect( turn.status === "running", ), }; - }), - getThreadProviderContext: (threadId, targetInstanceId) => - Effect.gen(function* () { + }, + ), + getThreadProviderContext: Effect.fn("ProjectionStore.getThreadProviderContext")( + function* (threadId, targetInstanceId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); @@ -6714,9 +6763,10 @@ export const layerMemory: Layer.Layer = Layer.effect( thread.id === target?.id, ), }; - }), - getRuntimeResponseContext: (threadId, requestId) => - Effect.gen(function* () { + }, + ), + getRuntimeResponseContext: Effect.fn("ProjectionStore.getRuntimeResponseContext")( + function* (threadId, requestId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); @@ -6736,35 +6786,35 @@ export const layerMemory: Layer.Layer = Layer.effect( session.id === request.responseCapability.providerSessionId, ), }; - }), - getCheckpointContext: (threadId) => - Effect.gen(function* () { - const projection = (yield* Ref.get(replayState)).projections.get(threadId); - if (projection === undefined) { - return yield* new ProjectionStoreThreadNotFoundError({ threadId }); - } - return { - runs: projection.runs.map(({ id, ordinal, status }) => ({ id, ordinal, status })), - checkpointScopes: projection.checkpointScopes.map(({ id, runId, kind, cwd }) => ({ - id, + }, + ), + getCheckpointContext: Effect.fn("ProjectionStore.getCheckpointContext")(function* (threadId) { + const projection = (yield* Ref.get(replayState)).projections.get(threadId); + if (projection === undefined) { + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + } + return { + runs: projection.runs.map(({ id, ordinal, status }) => ({ id, ordinal, status })), + checkpointScopes: projection.checkpointScopes.map(({ id, runId, kind, cwd }) => ({ + id, + runId, + kind, + cwd, + })), + checkpoints: projection.checkpoints.map( + ({ scopeId, runId, ordinalWithinScope, appRunOrdinal, status, ref }) => ({ + scopeId, runId, - kind, - cwd, - })), - checkpoints: projection.checkpoints.map( - ({ scopeId, runId, ordinalWithinScope, appRunOrdinal, status, ref }) => ({ - scopeId, - runId, - ordinalWithinScope, - appRunOrdinal, - status, - ref, - }), - ), - }; - }), - getCheckpointCaptureContext: (threadId, target) => - Effect.gen(function* () { + ordinalWithinScope, + appRunOrdinal, + status, + ref, + }), + ), + }; + }), + getCheckpointCaptureContext: Effect.fn("ProjectionStore.getCheckpointCaptureContext")( + function* (threadId, target) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); @@ -6782,9 +6832,10 @@ export const layerMemory: Layer.Layer = Layer.effect( ) .map((candidate) => candidate.ordinalWithinScope), }; - }), - getCheckpointRollbackContext: (threadId, target) => - Effect.gen(function* () { + }, + ), + getCheckpointRollbackContext: Effect.fn("ProjectionStore.getCheckpointRollbackContext")( + function* (threadId, target) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); @@ -6868,77 +6919,75 @@ export const layerMemory: Layer.Layer = Layer.effect( candidate.status === "ready", ), }; - }), - getRunMessage: (threadId, runId) => - Effect.gen(function* () { - const projection = (yield* Ref.get(replayState)).projections.get(threadId); - if (projection === undefined) - return yield* new ProjectionStoreThreadNotFoundError({ threadId }); - const run = projection.runs.find((candidate) => candidate.id === runId); - return projection.messages.find((message) => message.id === run?.userMessageId); - }), - canStartQueuedRun: (threadId) => - Effect.gen(function* () { - const projection = (yield* Ref.get(replayState)).projections.get(threadId); - if (projection === undefined) - return yield* new ProjectionStoreThreadNotFoundError({ threadId }); - return ( - projection.thread.archivedAt === null && - projection.thread.deletedAt === null && - projection.runs.some((run) => run.status === "queued") && - !projection.runs.some( - (run) => - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting" || - (run.status === "queued" && run.queueHeld === true), - ) - ); - }), - getThreadProjection: (threadId) => - Effect.gen(function* () { - const { projections: existing, providerThreadInstanceIds } = yield* Ref.get(replayState); - const readProjection = ( - targetThreadId: ThreadId, - seenThreadIds: ReadonlySet, - ): OrchestrationV2ThreadProjection | null => { - const projection = existing.get(targetThreadId); - if (!projection) { - return null; - } - const forkedFrom = projection.thread.forkedFrom; - if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) { - return withLocalVisibleTurnItems(projection); - } - const sourceProjection = readProjection( - forkedFrom.threadId, - new Set([...seenThreadIds, targetThreadId]), - ); - return { - ...projection, - visibleTurnItems: buildVisibleTurnItems({ - projection, - sourceProjection, - }), - }; - }; - const projection = readProjection(threadId, new Set()); + }, + ), + getRunMessage: Effect.fn("ProjectionStore.getRunMessage")(function* (threadId, runId) { + const projection = (yield* Ref.get(replayState)).projections.get(threadId); + if (projection === undefined) + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + const run = projection.runs.find((candidate) => candidate.id === runId); + return projection.messages.find((message) => message.id === run?.userMessageId); + }), + canStartQueuedRun: Effect.fn("ProjectionStore.canStartQueuedRun")(function* (threadId) { + const projection = (yield* Ref.get(replayState)).projections.get(threadId); + if (projection === undefined) + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + return ( + projection.thread.archivedAt === null && + projection.thread.deletedAt === null && + projection.runs.some((run) => run.status === "queued") && + !projection.runs.some( + (run) => + run.status === "preparing" || + run.status === "starting" || + run.status === "running" || + run.status === "waiting" || + (run.status === "queued" && run.queueHeld === true), + ) + ); + }), + getThreadProjection: Effect.fn("ProjectionStore.getThreadProjection")(function* (threadId) { + const { projections: existing, providerThreadInstanceIds } = yield* Ref.get(replayState); + const readProjection = ( + targetThreadId: ThreadId, + seenThreadIds: ReadonlySet, + ): OrchestrationV2ThreadProjection | null => { + const projection = existing.get(targetThreadId); if (!projection) { - return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + return null; + } + const forkedFrom = projection.thread.forkedFrom; + if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) { + return withLocalVisibleTurnItems(projection); } - // Match the SQL shell join, including provider threads stored on child app threads. + const sourceProjection = readProjection( + forkedFrom.threadId, + new Set([...seenThreadIds, targetThreadId]), + ); return { ...projection, - turnItems: projection.turnItems.map((item) => { - const owner = - item.type !== "subagent" || item.providerThreadId === null - ? undefined - : providerThreadInstanceIds.get(item.providerThreadId); - return owner === undefined ? item : { ...item, owningProviderInstanceId: owner }; + visibleTurnItems: buildVisibleTurnItems({ + projection, + sourceProjection, }), }; - }), + }; + const projection = readProjection(threadId, new Set()); + if (!projection) { + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + } + // Match the SQL shell join, including provider threads stored on child app threads. + return { + ...projection, + turnItems: projection.turnItems.map((item) => { + const owner = + item.type !== "subagent" || item.providerThreadId === null + ? undefined + : providerThreadInstanceIds.get(item.providerThreadId); + return owner === undefined ? item : { ...item, owningProviderInstanceId: owner }; + }), + }; + }), getTurnStartContext: (threadId, runId) => service.getThreadProjection(threadId).pipe( Effect.map((projection) => ({ diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 7dd397ecf54a..ac63f6391788 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -310,18 +310,19 @@ export const layer: Layer.Layer< const agentScope = yield* AgentScope; const completedTurnAnalytics = new Set(); - const makeDomainEvent = ( - input: ProviderEventIngestInput, - payloadInput: { - readonly type: OrchestrationV2DomainEvent["type"]; - readonly payload: OrchestrationV2DomainEvent["payload"]; - readonly threadId?: ThreadId; - readonly runId?: RunId | null; - readonly nodeId?: NodeId | null; - readonly occurredAt?: DateTime.Utc; - }, - ) => - Effect.gen(function* () { + const makeDomainEvent = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* ( + input: ProviderEventIngestInput, + payloadInput: { + readonly type: OrchestrationV2DomainEvent["type"]; + readonly payload: OrchestrationV2DomainEvent["payload"]; + readonly threadId?: ThreadId; + readonly runId?: RunId | null; + readonly nodeId?: NodeId | null; + readonly occurredAt?: DateTime.Utc; + }, + ) { const threadId = payloadInput.threadId ?? input.threadId; const eventId = yield* idAllocator.allocate.event({ threadId, @@ -517,339 +518,352 @@ export const layer: Layer.Layer< return events; }); - const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => - Effect.gen(function* () { - switch (input.event.type) { - case "app_thread.created": { - const offered = input.event.appThread; - const existing = yield* projections.getThread(offered.id).pipe( - Effect.catchTags({ - ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), - }), - ); - if ( - existing !== null && - existing.title === offered.title && - modelSelectionsEqual(existing.modelSelection, offered.modelSelection) - ) - return []; - const now = yield* DateTime.now; - return [ - yield* makeDomainEvent(input, { - type: existing === null ? "thread.created" : "thread.metadata-updated", - threadId: offered.id, - payload: - existing === null - ? offered - : { - ...existing, - title: offered.title, - modelSelection: offered.modelSelection, - updatedAt: now, - }, - occurredAt: now, - }), - ]; - } - case "provider_session.updated": - return [ - yield* makeDomainEvent(input, { - type: "provider-session.updated", - payload: input.event.providerSession, - }), - ]; - case "provider_thread.updated": { - const threadId = input.event.providerThread.appThreadId ?? input.threadId; - const updated = yield* makeDomainEvent(input, { - type: "provider-thread.updated", - threadId, - payload: input.event.providerThread, - }); - return input.event.retainedNativeTurnIds === undefined - ? [updated] - : [ - updated, - ...(yield* rollBackRewoundRuns(input, { - threadId, - providerThreadId: input.event.providerThread.id, - retainedNativeTurnIds: new Set(input.event.retainedNativeTurnIds), - })), - ]; - } - case "provider_turn.updated": - return [ - ...(["completed", "interrupted", "failed", "cancelled"].includes( - input.event.providerTurn.status, + const normalize: ProviderEventIngestorV2Shape["normalize"] = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced( + function* (input) { + switch (input.event.type) { + case "app_thread.created": { + const offered = input.event.appThread; + const existing = yield* projections.getThread(offered.id).pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + ); + if ( + existing !== null && + existing.title === offered.title && + modelSelectionsEqual(existing.modelSelection, offered.modelSelection) ) - ? yield* dismissNativeUserInputs( - input, - input.event.providerTurn.id, - input.event.threadId, - ) - : []), - yield* makeDomainEvent(input, { - type: "provider-turn.updated", - ...(input.event.threadId === undefined ? {} : { threadId: input.event.threadId }), - payload: input.event.providerTurn, - nodeId: input.event.providerTurn.nodeId, - }), - ]; - case "node.updated": - // Adapters send an unchanged node again with each text delta. Compare with the - // stored node, not the last one sent, because the server also writes nodes. - if (yield* projections.isNodeStored(input.event.node)) return []; - return [ - yield* makeDomainEvent(input, { - type: "node.updated", - threadId: input.event.node.threadId, - payload: input.event.node, - runId: input.event.node.runId, - nodeId: input.event.node.id, - }), - ]; - case "subagent.updated": - return [ - yield* makeDomainEvent(input, { - type: "subagent.updated", - threadId: input.event.subagent.threadId, - payload: input.event.subagent, - runId: input.event.subagent.runId, - nodeId: input.event.subagent.id, - }), - ]; - case "message.updated": - return [ - yield* makeDomainEvent(input, { - type: "message.updated", - threadId: input.event.message.threadId, - payload: input.event.message, - runId: input.event.message.runId, - nodeId: input.event.message.nodeId, - }), - ]; - case "turn_item.updated": - return [ - yield* makeDomainEvent(input, { - type: "turn-item.updated", - threadId: input.event.turnItem.threadId, - payload: boundFileChangeForStorage( - stripUnservedToolOutputImageBytes(input.event.turnItem), - ), - runId: input.event.turnItem.runId, - nodeId: input.event.turnItem.nodeId, - }), - ]; - case "runtime_request.updated": - return [ - yield* makeDomainEvent(input, { - type: "runtime-request.updated", - ...(input.event.threadId === undefined ? {} : { threadId: input.event.threadId }), - payload: input.event.runtimeRequest, - nodeId: input.event.runtimeRequest.nodeId, - }), - ]; - case "plan.updated": { - const occurredAt = yield* DateTime.now; - const plan = input.event.plan; - const previous = - plan.kind === "todo_list" - ? yield* projections.getPlan(plan.threadId, plan.id) - : undefined; - const payload = - plan.kind === "todo_list" - ? withPlanStepDurations( - plan, - previous?.kind === "todo_list" ? previous : undefined, + return []; + const now = yield* DateTime.now; + return [ + yield* makeDomainEvent(input, { + type: existing === null ? "thread.created" : "thread.metadata-updated", + threadId: offered.id, + payload: + existing === null + ? offered + : { + ...existing, + title: offered.title, + modelSelection: offered.modelSelection, + updatedAt: now, + }, + occurredAt: now, + }), + ]; + } + case "provider_session.updated": + return [ + yield* makeDomainEvent(input, { + type: "provider-session.updated", + payload: input.event.providerSession, + }), + ]; + case "provider_thread.updated": { + const threadId = input.event.providerThread.appThreadId ?? input.threadId; + const updated = yield* makeDomainEvent(input, { + type: "provider-thread.updated", + threadId, + payload: input.event.providerThread, + }); + return input.event.retainedNativeTurnIds === undefined + ? [updated] + : [ + updated, + ...(yield* rollBackRewoundRuns(input, { + threadId, + providerThreadId: input.event.providerThread.id, + retainedNativeTurnIds: new Set(input.event.retainedNativeTurnIds), + })), + ]; + } + case "provider_turn.updated": + return [ + ...(["completed", "interrupted", "failed", "cancelled"].includes( + input.event.providerTurn.status, + ) + ? yield* dismissNativeUserInputs( + input, + input.event.providerTurn.id, + input.event.threadId, + ) + : []), + yield* makeDomainEvent(input, { + type: "provider-turn.updated", + ...(input.event.threadId === undefined ? {} : { threadId: input.event.threadId }), + payload: input.event.providerTurn, + nodeId: input.event.providerTurn.nodeId, + }), + ]; + case "node.updated": + // Adapters send an unchanged node again with each text delta. Compare with the + // stored node, not the last one sent, because the server also writes nodes. + if (yield* projections.isNodeStored(input.event.node)) return []; + return [ + yield* makeDomainEvent(input, { + type: "node.updated", + threadId: input.event.node.threadId, + payload: input.event.node, + runId: input.event.node.runId, + nodeId: input.event.node.id, + }), + ]; + case "subagent.updated": + return [ + yield* makeDomainEvent(input, { + type: "subagent.updated", + threadId: input.event.subagent.threadId, + payload: input.event.subagent, + runId: input.event.subagent.runId, + nodeId: input.event.subagent.id, + }), + ]; + case "message.updated": + return [ + yield* makeDomainEvent(input, { + type: "message.updated", + threadId: input.event.message.threadId, + payload: input.event.message, + runId: input.event.message.runId, + nodeId: input.event.message.nodeId, + }), + ]; + case "turn_item.updated": + return [ + yield* makeDomainEvent(input, { + type: "turn-item.updated", + threadId: input.event.turnItem.threadId, + payload: boundFileChangeForStorage( + stripUnservedToolOutputImageBytes(input.event.turnItem), + ), + runId: input.event.turnItem.runId, + nodeId: input.event.turnItem.nodeId, + }), + ]; + case "runtime_request.updated": + return [ + yield* makeDomainEvent(input, { + type: "runtime-request.updated", + ...(input.event.threadId === undefined ? {} : { threadId: input.event.threadId }), + payload: input.event.runtimeRequest, + nodeId: input.event.runtimeRequest.nodeId, + }), + ]; + case "plan.updated": { + const occurredAt = yield* DateTime.now; + const plan = input.event.plan; + const previous = + plan.kind === "todo_list" + ? yield* projections.getPlan(plan.threadId, plan.id) + : undefined; + const payload = + plan.kind === "todo_list" + ? withPlanStepDurations( + plan, + previous?.kind === "todo_list" ? previous : undefined, + occurredAt, + ) + : plan; + return [ + yield* makeDomainEvent(input, { + type: "plan.updated", + threadId: plan.threadId, + payload, + runId: plan.runId, + nodeId: plan.nodeId, + occurredAt, + }), + ]; + } + case "command_output.delta": + // Live output is routed to the command output hub before ingestion. + return []; + case "turn.terminal": + const dismissed = yield* dismissNativeUserInputs(input, input.event.providerTurnId); + if (input.event.status !== "failed") { + return dismissed; + } + const occurredAt = yield* DateTime.now; + const failure = (yield* agentScope.oomKilled(input.threadId)) + ? { ...input.event.failure, message: "Killed: out of memory.", code: "oom_kill" } + : input.event.failure; + return [ + ...dismissed, + yield* makeDomainEvent(input, { + type: "turn-item.updated", + payload: makeProviderFailureTurnItem({ + driver: input.event.driver, + threadId: input.threadId, + runId: input.runId ?? null, + nodeId: input.nodeId ?? null, + providerThreadId: input.event.providerThreadId, + providerTurnId: input.event.providerTurnId, + itemOrdinal: input.event.failureItemOrdinal, + failure, + ...(input.event.retry === undefined ? {} : { retry: input.event.retry }), + ...(input.event.retryStartedAt === undefined + ? {} + : { retryStartedAt: input.event.retryStartedAt }), occurredAt, - ) - : plan; - return [ - yield* makeDomainEvent(input, { - type: "plan.updated", - threadId: plan.threadId, - payload, - runId: plan.runId, - nodeId: plan.nodeId, - occurredAt, - }), - ]; + }), + }), + ]; } - case "command_output.delta": - // Live output is routed to the command output hub before ingestion. - return []; - case "turn.terminal": - const dismissed = yield* dismissNativeUserInputs(input, input.event.providerTurnId); - if (input.event.status !== "failed") { - return dismissed; - } - const occurredAt = yield* DateTime.now; - const failure = (yield* agentScope.oomKilled(input.threadId)) - ? { ...input.event.failure, message: "Killed: out of memory.", code: "oom_kill" } - : input.event.failure; - return [ - ...dismissed, - yield* makeDomainEvent(input, { - type: "turn-item.updated", - payload: makeProviderFailureTurnItem({ - driver: input.event.driver, + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderEventNormalizeError({ + providerSessionId: input.providerSessionId, threadId: input.threadId, - runId: input.runId ?? null, - nodeId: input.nodeId ?? null, - providerThreadId: input.event.providerThreadId, - providerTurnId: input.event.providerTurnId, - itemOrdinal: input.event.failureItemOrdinal, - failure, - ...(input.event.retry === undefined ? {} : { retry: input.event.retry }), - ...(input.event.retryStartedAt === undefined - ? {} - : { retryStartedAt: input.event.retryStartedAt }), - occurredAt, + providerEvent: input.event, + cause, }), - }), - ]; - } - }).pipe( - Effect.mapError( - (cause) => - new ProviderEventNormalizeError({ - providerSessionId: input.providerSessionId, - threadId: input.threadId, - providerEvent: input.event, - cause, - }), - ), + ), + ), ); return ProviderEventIngestorV2.of({ normalize, - ingestNormalized: (input) => - Effect.gen(function* () { - const events = yield* normalize(input); - if (events.length === 0) { - return []; - } - const mapWriteError = (cause: unknown) => - new ProviderEventPublishError({ - providerSessionId: input.providerSessionId, - eventCount: events.length, - cause, - }); - if (input.writeIfProviderThreadOwner !== undefined) { - const ownerResult = yield* eventSink - .writeIfProviderThreadOwner({ - guardPendingUserInputCancellations: true, - ...(input.commandId === undefined ? {} : { commandId: input.commandId }), - ...input.writeIfProviderThreadOwner, - events, - }) - .pipe(Effect.mapError(mapWriteError)); - return ownerResult.storedEvents; - } - if (input.writeIfRunCurrent === undefined) { - return yield* eventSink - .write({ + ingestNormalized: + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced( + function* (input) { + const events = yield* normalize(input); + if (events.length === 0) { + return []; + } + const mapWriteError = (cause: unknown) => + new ProviderEventPublishError({ + providerSessionId: input.providerSessionId, + eventCount: events.length, + cause, + }); + if (input.writeIfProviderThreadOwner !== undefined) { + const ownerResult = yield* eventSink + .writeIfProviderThreadOwner({ + guardPendingUserInputCancellations: true, + ...(input.commandId === undefined ? {} : { commandId: input.commandId }), + ...input.writeIfProviderThreadOwner, + events, + }) + .pipe(Effect.mapError(mapWriteError)); + return ownerResult.storedEvents; + } + if (input.writeIfRunCurrent === undefined) { + return yield* eventSink + .write({ + guardPendingUserInputCancellations: true, + ...(input.commandId === undefined ? {} : { commandId: input.commandId }), + events, + }) + .pipe(Effect.mapError(mapWriteError)); + } + const result = yield* eventSink + .writeIfRunCurrent({ guardPendingUserInputCancellations: true, ...(input.commandId === undefined ? {} : { commandId: input.commandId }), + threadId: input.threadId, + ...input.writeIfRunCurrent, events, }) .pipe(Effect.mapError(mapWriteError)); - } - const result = yield* eventSink - .writeIfRunCurrent({ - guardPendingUserInputCancellations: true, - ...(input.commandId === undefined ? {} : { commandId: input.commandId }), - threadId: input.threadId, - ...input.writeIfRunCurrent, - events, - }) - .pipe(Effect.mapError(mapWriteError)); - return result.storedEvents; - }).pipe( - Effect.flatMap((storedEvents) => - Effect.gen(function* () { - if (input.event.type !== "app_thread.created") return storedEvents; - const created = storedEvents.find((stored) => stored.event.type === "thread.created"); - if (created?.event.type !== "thread.created") return storedEvents; - const child = created.event.payload; - if ( - child.worktreePath === null || - child.branch === null || - !isTemporaryWorktreeBranch(child.branch) - ) - return storedEvents; - // Git renames before scanning committed threads. If that scan missed this - // creation, HEAD already has the new name, even if the parent moved away. - const branch = yield* git.resolveBranchName(child.worktreePath, "HEAD"); - if (branch === null || branch === child.branch) return storedEvents; - const now = yield* DateTime.now; - const event = yield* makeDomainEvent(input, { - type: "thread.metadata-updated", - threadId: child.id, - payload: { ...child, branch, updatedAt: now }, - occurredAt: now, - }); - const synced = yield* eventSink.write({ events: [event] }); - return [...storedEvents, ...synced]; - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Native thread worktree branch recheck failed", { - threadId: - input.event.type === "app_thread.created" - ? input.event.appThread.id - : input.threadId, - cause, - }).pipe(Effect.as(storedEvents)), - ), - ), - ), - Effect.flatMap((storedEvents) => - storedEvents.length === 0 || input.event.type !== "subagent.updated" - ? Effect.succeed(storedEvents) - : syncSubagentThreadModel(input, input.event.subagent).pipe( - Effect.map((synced) => [...storedEvents, ...synced]), - Effect.mapError( - (cause) => - new ProviderEventPublishError({ - providerSessionId: input.providerSessionId, - eventCount: 1, - cause, - }), + return result.storedEvents; + }, + (effect, input) => + effect.pipe( + Effect.flatMap((storedEvents) => + Effect.gen(function* () { + if (input.event.type !== "app_thread.created") return storedEvents; + const created = storedEvents.find( + (stored) => stored.event.type === "thread.created", + ); + if (created?.event.type !== "thread.created") return storedEvents; + const child = created.event.payload; + if ( + child.worktreePath === null || + child.branch === null || + !isTemporaryWorktreeBranch(child.branch) + ) + return storedEvents; + // Git renames before scanning committed threads. If that scan missed this + // creation, HEAD already has the new name, even if the parent moved away. + const branch = yield* git.resolveBranchName(child.worktreePath, "HEAD"); + if (branch === null || branch === child.branch) return storedEvents; + const now = yield* DateTime.now; + const event = yield* makeDomainEvent(input, { + type: "thread.metadata-updated", + threadId: child.id, + payload: { ...child, branch, updatedAt: now }, + occurredAt: now, + }); + const synced = yield* eventSink.write({ events: [event] }); + return [...storedEvents, ...synced]; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Native thread worktree branch recheck failed", { + threadId: + input.event.type === "app_thread.created" + ? input.event.appThread.id + : input.threadId, + cause, + }).pipe(Effect.as(storedEvents)), ), ), - ), - Effect.tap((storedEvents) => - Effect.gen(function* () { - if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") return; - const providerTurn = input.event.providerTurn; - if ( - providerTurn.status !== "completed" && - providerTurn.status !== "failed" && - providerTurn.status !== "interrupted" && - providerTurn.status !== "cancelled" - ) - return; - const key = `${input.providerInstanceId}:${providerTurn.id}`; - if (completedTurnAnalytics.has(key)) return; - completedTurnAnalytics.add(key); - if (completedTurnAnalytics.size > 4096) { - const oldest = completedTurnAnalytics.values().next().value; - if (oldest !== undefined) completedTurnAnalytics.delete(oldest); - } - yield* analytics.record( - providerTurnAnalyticsProperties({ - driver: input.event.driver, - providerTurn, - ...(input.analyticsContext === undefined - ? {} - : { context: input.analyticsContext }), + ), + Effect.flatMap((storedEvents) => + storedEvents.length === 0 || input.event.type !== "subagent.updated" + ? Effect.succeed(storedEvents) + : syncSubagentThreadModel(input, input.event.subagent).pipe( + Effect.map((synced) => [...storedEvents, ...synced]), + Effect.mapError( + (cause) => + new ProviderEventPublishError({ + providerSessionId: input.providerSessionId, + eventCount: 1, + cause, + }), + ), + ), + ), + Effect.tap((storedEvents) => + Effect.gen(function* () { + if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") + return; + const providerTurn = input.event.providerTurn; + if ( + providerTurn.status !== "completed" && + providerTurn.status !== "failed" && + providerTurn.status !== "interrupted" && + providerTurn.status !== "cancelled" + ) + return; + const key = `${input.providerInstanceId}:${providerTurn.id}`; + if (completedTurnAnalytics.has(key)) return; + completedTurnAnalytics.add(key); + if (completedTurnAnalytics.size > 4096) { + const oldest = completedTurnAnalytics.values().next().value; + if (oldest !== undefined) completedTurnAnalytics.delete(oldest); + } + yield* analytics.record( + providerTurnAnalyticsProperties({ + driver: input.event.driver, + providerTurn, + ...(input.analyticsContext === undefined + ? {} + : { context: input.analyticsContext }), + }), + ); }), - ); - }), - ), - (effect) => - input.event.type === "app_thread.created" - ? threadCommands.withLock(input.event.appThread.id, effect) - : effect, + ), + (effect) => + input.event.type === "app_thread.created" + ? threadCommands.withLock(input.event.appThread.id, effect) + : effect, + ), ), }); }), diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index c5a80e70281b..be8020ac425f 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -38,28 +38,32 @@ export function buildActiveShellSnapshot(input: { * because the server shares one SQLite connection and decoding a large shell takes longer than * reading it. */ -export const loadShellSnapshotParts = (input: { +export const loadShellSnapshotParts = Effect.fn("ShellStream.loadShellSnapshotParts")(function* < + E1, + E2, + E3, + E4, +>(input: { readonly sql: SqlClient.SqlClient; readonly readThreads: Effect.Effect, E1>; readonly listProjects: Effect.Effect, E3>; readonly latestSequence: Effect.Effect; -}) => - Effect.gen(function* () { - const read = yield* input.sql - .withTransaction( - Effect.all({ - decodeThreads: input.readThreads, - projects: input.listProjects, - snapshotSequence: input.latestSequence, - }), - ) - .pipe(NodeSqliteClient.readOnly); - return { - projects: read.projects, - threads: yield* read.decodeThreads, - snapshotSequence: read.snapshotSequence, - }; - }); +}) { + const read = yield* input.sql + .withTransaction( + Effect.all({ + decodeThreads: input.readThreads, + projects: input.listProjects, + snapshotSequence: input.latestSequence, + }), + ) + .pipe(NodeSqliteClient.readOnly); + return { + projects: read.projects, + threads: yield* read.decodeThreads, + snapshotSequence: read.snapshotSequence, + }; +}); export type ShellApplicationEvent = | Pick< diff --git a/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts index e28deae20728..00340e2e06fd 100644 --- a/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts +++ b/apps/server/src/orchestration-v2/ThreadLiveEventCoalescer.ts @@ -80,153 +80,154 @@ export function coalesceLiveToolUpdatedEvents( return survivors; } -export const makeThreadLiveEventCoalescer = (options?: { +export const makeThreadLiveEventCoalescer = Effect.fn( + "ThreadLiveEventCoalescer.makeThreadLiveEventCoalescer", +)(function* (options?: { readonly coalesceWindow?: Duration.Input; readonly maxItems?: number; readonly maxSerializedBytes?: number; -}) => - Effect.gen(function* () { - const coalescerScope = yield* Effect.scope; - const budget = yield* makeLiveStreamBudget(options); - const cleanupComplete = yield* Deferred.make(); - const output = yield* Queue.unbounded< - RetainedLiveItem, - E | LiveStreamBufferError | Cause.Done - >(); - const mutex = yield* Semaphore.make(1); - const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; - let pendingUpdates: Array> = []; - let windowGeneration = 0; - let windowFiber: Fiber.Fiber | null = null; - let closed = false; - - const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { - const fiber = windowFiber; - if (!fiber) { - return; - } - windowFiber = null; - yield* Fiber.interrupt(fiber); - }); +}) { + const coalescerScope = yield* Effect.scope; + const budget = yield* makeLiveStreamBudget(options); + const cleanupComplete = yield* Deferred.make(); + const output = yield* Queue.unbounded< + RetainedLiveItem, + E | LiveStreamBufferError | Cause.Done + >(); + const mutex = yield* Semaphore.make(1); + const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; + let pendingUpdates: Array> = []; + let windowGeneration = 0; + let windowFiber: Fiber.Fiber | null = null; + let closed = false; + + const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { + const fiber = windowFiber; + if (!fiber) { + return; + } + windowFiber = null; + yield* Fiber.interrupt(fiber); + }); - const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* () { - if (pendingUpdates.length === 0) { - return; - } - const items = yield* budget.replace( - pendingUpdates, - coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)), - ); - pendingUpdates = []; - yield* Queue.offerAll(output, items); - }, Effect.uninterruptible); - - const flushWindow = (generation: number) => - Effect.sleep(coalesceWindow).pipe( - Effect.andThen( - mutex.withPermits(1)( - Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), - ), - ), - Effect.ensuring( - Effect.sync(() => { - if (generation === windowGeneration) { - windowFiber = null; - } - }), + const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* () { + if (pendingUpdates.length === 0) { + return; + } + const items = yield* budget.replace( + pendingUpdates, + coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)), + ); + pendingUpdates = []; + yield* Queue.offerAll(output, items); + }, Effect.uninterruptible); + + const flushWindow = (generation: number) => + Effect.sleep(coalesceWindow).pipe( + Effect.andThen( + mutex.withPermits(1)( + Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), ), - Effect.catchTags({ LiveStreamBufferError: () => Effect.void }), - ); + ), + Effect.ensuring( + Effect.sync(() => { + if (generation === windowGeneration) { + windowFiber = null; + } + }), + ), + Effect.catchTags({ LiveStreamBufferError: () => Effect.void }), + ); - // Keep each source batch together so a synchronization marker cannot pass - // events already pulled from PubSub but still being coalesced. - const offerAll = Effect.fn("ThreadLiveEventCoalescer.offerAll")(function* ( - inputs: ReadonlyArray, - ) { - yield* mutex.withPermits(1)( - Effect.forEach( - inputs, - (input) => - Effect.gen(function* () { - yield* budget.check; - if (input.kind === "event") { - yield* budget.retain(input).pipe( - Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))), - Effect.uninterruptible, - ); + // Keep each source batch together so a synchronization marker cannot pass + // events already pulled from PubSub but still being coalesced. + const offerAll = Effect.fn("ThreadLiveEventCoalescer.offerAll")(function* ( + inputs: ReadonlyArray, + ) { + yield* mutex.withPermits(1)( + Effect.forEach( + inputs, + (input) => + Effect.gen(function* () { + yield* budget.check; + if (input.kind === "event") { + yield* budget.retain(input).pipe( + Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))), + Effect.uninterruptible, + ); + } + if (input.kind === "event" && isToolUpdated(input)) { + if (pendingUpdates.length === 1) { + const generation = ++windowGeneration; + windowFiber = yield* Effect.forkIn(flushWindow(generation), coalescerScope); } - if (input.kind === "event" && isToolUpdated(input)) { - if (pendingUpdates.length === 1) { - const generation = ++windowGeneration; - windowFiber = yield* Effect.forkIn(flushWindow(generation), coalescerScope); - } - if (pendingUpdates.length >= MAX_PENDING_UPDATES) { - yield* cancelWindow(); - windowGeneration += 1; - yield* flushPending(); - } - return; + if (pendingUpdates.length >= MAX_PENDING_UPDATES) { + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); } + return; + } - yield* cancelWindow(); - windowGeneration += 1; - yield* flushPending(); - if (input.kind === "synchronized") { - yield* budget.retain({ kind: "synchronized" as const }).pipe( - Effect.flatMap((marker) => Queue.offer(output, marker)), - Effect.uninterruptible, - ); - } - }), - { discard: true }, - ), - ); - }); - - const close = (cause?: Cause.Cause) => - mutex.withPermits(1)( - Effect.gen(function* () { - if (closed) { - return; - } - closed = true; - windowGeneration += 1; - yield* cancelWindow(); - budget.release(pendingUpdates); - pendingUpdates = []; - budget.release(yield* Queue.clear(output).pipe(Effect.orElseSucceed(() => []))); - if (cause) { - yield* Queue.failCause(output, cause); - } - yield* Queue.shutdown(output); - yield* Deferred.succeed(cleanupComplete, undefined); - }), - ); + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); + if (input.kind === "synchronized") { + yield* budget.retain({ kind: "synchronized" as const }).pipe( + Effect.flatMap((marker) => Queue.offer(output, marker)), + Effect.uninterruptible, + ); + } + }), + { discard: true }, + ), + ); + }); - yield* Effect.addFinalizer(() => close()); - yield* budget.failed.pipe( - Effect.catchTags({ LiveStreamBufferError: (error) => close(Cause.fail(error)) }), - Effect.forkScoped, + const close = (cause?: Cause.Cause) => + mutex.withPermits(1)( + Effect.gen(function* () { + if (closed) { + return; + } + closed = true; + windowGeneration += 1; + yield* cancelWindow(); + budget.release(pendingUpdates); + pendingUpdates = []; + budget.release(yield* Queue.clear(output).pipe(Effect.orElseSucceed(() => []))); + if (cause) { + yield* Queue.failCause(output, cause); + } + yield* Queue.shutdown(output); + yield* Deferred.succeed(cleanupComplete, undefined); + }), ); - return { - offer: (input: ThreadLiveInput) => offerAll([input]), - close, - end: mutex.withPermits(1)( - Effect.gen(function* () { - yield* cancelWindow(); - windowGeneration += 1; - yield* flushPending(); - yield* Queue.end(output); - }), - ), - offerAll, - stream: budget.deliver(Stream.fromQueue(output)), - failed: budget.failed, - closed: Deferred.await(cleanupComplete), - usage: budget.usage, - } as const; - }); + yield* Effect.addFinalizer(() => close()); + yield* budget.failed.pipe( + Effect.catchTags({ LiveStreamBufferError: (error) => close(Cause.fail(error)) }), + Effect.forkScoped, + ); + + return { + offer: (input: ThreadLiveInput) => offerAll([input]), + close, + end: mutex.withPermits(1)( + Effect.gen(function* () { + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); + yield* Queue.end(output); + }), + ), + offerAll, + stream: budget.deliver(Stream.fromQueue(output)), + failed: budget.failed, + closed: Deferred.await(cleanupComplete), + usage: budget.usage, + } as const; +}); export const coalesceThreadLiveStream = (source: Stream.Stream) => Stream.unwrap( diff --git a/apps/server/src/orchestration-v2/TurnItemPositionStore.ts b/apps/server/src/orchestration-v2/TurnItemPositionStore.ts index 9e60c0f0fdfc..a004191e0952 100644 --- a/apps/server/src/orchestration-v2/TurnItemPositionStore.ts +++ b/apps/server/src/orchestration-v2/TurnItemPositionStore.ts @@ -39,34 +39,31 @@ export const layer: Layer.Layer - Effect.gen(function* () { - const existing = yield* sql<{ readonly ordinal: number }>` + const allocate: TurnItemPositionStoreV2Shape["allocate"] = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced( + function* ({ threadId, turnItemId, runId, runOrdinal: suppliedRunOrdinal }) { + const existing = yield* sql<{ readonly ordinal: number }>` SELECT ordinal FROM orchestration_v2_turn_item_positions WHERE thread_id = ${threadId} AND turn_item_id = ${turnItemId} LIMIT 1 `; - if (existing[0] !== undefined) return existing[0].ordinal; + if (existing[0] !== undefined) return existing[0].ordinal; - const runRows = - runId === null - ? [] - : yield* sql<{ readonly ordinal: number }>` + const runRows = + runId === null + ? [] + : yield* sql<{ readonly ordinal: number }>` SELECT ordinal FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND run_id = ${runId} LIMIT 1 `; - const runOrdinal = suppliedRunOrdinal ?? runRows[0]?.ordinal ?? null; - const lowerBound = runOrdinal === null ? 0 : runOrdinal * 1_000_000; - const upperBound = runOrdinal === null ? 999_999 : lowerBound + 999_999; - yield* sql` + const runOrdinal = suppliedRunOrdinal ?? runRows[0]?.ordinal ?? null; + const lowerBound = runOrdinal === null ? 0 : runOrdinal * 1_000_000; + const upperBound = runOrdinal === null ? 999_999 : lowerBound + 999_999; + yield* sql` INSERT INTO orchestration_v2_turn_item_positions (thread_id, turn_item_id, ordinal) SELECT ${threadId}, @@ -78,27 +75,30 @@ export const layer: Layer.Layer` + const rows = yield* sql<{ readonly ordinal: number }>` SELECT ordinal FROM orchestration_v2_turn_item_positions WHERE thread_id = ${threadId} AND turn_item_id = ${turnItemId} LIMIT 1 `; - const ordinal = rows[0]?.ordinal; - if (ordinal === undefined) { - return yield* new TurnItemPositionStoreError({ - threadId, - turnItemId, - cause: "Position allocation returned no row.", - }); - } - return ordinal; - }).pipe( - Effect.mapError((cause) => - isTurnItemPositionStoreError(cause) - ? cause - : new TurnItemPositionStoreError({ threadId, turnItemId, cause }), - ), + const ordinal = rows[0]?.ordinal; + if (ordinal === undefined) { + return yield* new TurnItemPositionStoreError({ + threadId, + turnItemId, + cause: "Position allocation returned no row.", + }); + } + return ordinal; + }, + (effect, { threadId, turnItemId }) => + effect.pipe( + Effect.mapError((cause) => + isTurnItemPositionStoreError(cause) + ? cause + : new TurnItemPositionStoreError({ threadId, turnItemId, cause }), + ), + ), ); return TurnItemPositionStoreV2.of({ From c911f654b9fb604e93808c4cb216eb0e99825878 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:30:59 -0700 Subject: [PATCH 007/458] refactor(orchestration): trace reusable service operations (#21995) --- .../CheckpointRollbackService.ts | 32 +- .../src/orchestration-v2/CheckpointService.ts | 38 +- .../DelegatedTaskQuestionReactor.ts | 22 +- .../src/orchestration-v2/EffectWorker.ts | 35 +- .../src/orchestration-v2/Orchestrator.ts | 9623 ++++++++--------- .../ProviderAdapterRegistry.ts | 70 +- .../ProviderRuntimeRecoveryService.ts | 103 +- .../ProviderSessionManager.ts | 652 +- .../orchestration-v2/ProviderSwitchService.ts | 25 +- .../ProviderTurnControlService.ts | 219 +- .../ProviderTurnStartService.ts | 125 +- .../PullRequestWatchReactor.ts | 66 +- .../orchestration-v2/RunExecutionService.ts | 1487 ++- .../RunFinalizationService.ts | 9 +- .../orchestration-v2/RuntimeRequestService.ts | 31 +- .../src/orchestration-v2/ThreadForkService.ts | 121 +- .../orchestration-v2/ThreadLaunchService.ts | 7 +- .../ThreadManagementService.ts | 481 +- .../legacy/LegacyV1ThreadImporter.ts | 21 +- 19 files changed, 6597 insertions(+), 6570 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 03efcaa7f382..96a5e10c9892 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -355,26 +355,30 @@ export const layer: Layer.Layer< }); return CheckpointRollbackServiceV2.of({ - execute: (input) => - Effect.gen(function* () { + // The inner execute owns the rollback span; this wrapper selects the workspace lease. + execute: Effect.fnUntraced( + function* (input) { // Storage cleanup removes worktrees under the same lease. const thread = yield* projections.getThread(input.threadId); yield* thread.worktreePath === null ? execute(input) : withWorkspaceLease(path.resolve(thread.worktreePath), execute(input)); - }).pipe( - Effect.mapError((cause) => - isCheckpointRollbackExecutionError(cause) - ? cause - : new CheckpointRollbackExecutionError({ - reason: "unexpected-failure", - threadId: input.threadId, - providerThreadId: input.providerThreadId, - checkpointId: input.checkpointId, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isCheckpointRollbackExecutionError(cause) + ? cause + : new CheckpointRollbackExecutionError({ + reason: "unexpected-failure", + threadId: input.threadId, + providerThreadId: input.providerThreadId, + checkpointId: input.checkpointId, + cause, + }), + ), ), - ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index c2f81d044da2..529ea60d36c0 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -184,7 +184,7 @@ function checkpointIdForScopeOrdinal( }); } -function makeRootRunScope(input: { +const makeRootRunScope = Effect.fn("CheckpointService.makeRootRunScope")(function* (input: { readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly threadId: ThreadId; readonly runId: RunId; @@ -193,26 +193,24 @@ function makeRootRunScope(input: { readonly cwd: string; readonly createdAt: DateTime.Utc; }) { - return Effect.gen(function* () { - const scopeId = yield* input.idAllocator.allocate.checkpointScope({ - threadId: input.threadId, - name: ROOT_CHECKPOINT_SCOPE_NAME, - }); - return { - id: scopeId, - threadId: input.threadId, - runId: input.runId, - nodeId: input.rootNodeId, - parentScopeId: null, - providerThreadId: input.providerThreadId, - kind: "root_run", - ordinalWithinParent: 0, - advancesAppRunCount: true, - cwd: input.cwd, - createdAt: input.createdAt, - } satisfies OrchestrationV2CheckpointScope; + const scopeId = yield* input.idAllocator.allocate.checkpointScope({ + threadId: input.threadId, + name: ROOT_CHECKPOINT_SCOPE_NAME, }); -} + return { + id: scopeId, + threadId: input.threadId, + runId: input.runId, + nodeId: input.rootNodeId, + parentScopeId: null, + providerThreadId: input.providerThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: input.cwd, + createdAt: input.createdAt, + } satisfies OrchestrationV2CheckpointScope; +}); function makeCheckpoint(input: { readonly id: CheckpointId; diff --git a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts index 86b369f91367..50ae6c74678f 100644 --- a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts +++ b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts @@ -58,8 +58,9 @@ const make = Effect.gen(function* () { }); }); - const handle: DelegatedTaskQuestionReactor["Service"]["handle"] = (event) => - Effect.gen(function* () { + // Runs for every domain event; most events return before any work is needed. + const handle: DelegatedTaskQuestionReactor["Service"]["handle"] = Effect.fnUntraced( + function* (event) { if (event.type === "run.updated") { if (!["completed", "failed", "waiting"].includes(event.payload.status)) return; const parent = yield* projections.getThreadRecords(event.threadId, ["subagents"]); @@ -154,14 +155,17 @@ const make = Effect.gen(function* () { return; } yield* wakeParent(task, request); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("waking the parent of a delegated task question failed", { - threadId: event.threadId, - cause, - }), + }, + (effect, event) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("waking the parent of a delegated task question failed", { + threadId: event.threadId, + cause, + }), + ), ), - ); + ); const start: DelegatedTaskQuestionReactor["Service"]["start"] = () => forkParked( diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index a44b60c2498d..d47635a09104 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -673,8 +673,9 @@ export const layerWithOptions = ( ? requeueClaim(effect, cause) : terminalizeClaim(effect, cause); - const runOnce = (excludeRestartContinuations = false) => - Effect.gen(function* () { + // The worker probes for work repeatedly, including when the outbox is empty. + const runOnce = Effect.fnUntraced( + function* (excludeRestartContinuations = false) { const claimExit = yield* Effect.exit( outbox.claimNext({ workerId, leaseDurationMs, excludeRestartContinuations }), ); @@ -781,13 +782,16 @@ export const layerWithOptions = ( }); } return true; - }).pipe( - Effect.mapError((cause) => - isOrchestrationEffectWorkerError(cause) - ? cause - : new OrchestrationEffectWorkerError({ operation: "run", cause }), + }, + (effect, _excludeRestartContinuations?: boolean) => + effect.pipe( + Effect.mapError((cause) => + isOrchestrationEffectWorkerError(cause) + ? cause + : new OrchestrationEffectWorkerError({ operation: "run", cause }), + ), ), - ); + ); return OrchestrationEffectWorkerV2.of({ awaitWork: outbox.awaitAvailable, @@ -802,14 +806,13 @@ export const layerWithOptions = ( }), ), ), - drain: (maxEffects = Number.MAX_SAFE_INTEGER) => - Effect.gen(function* () { - let completed = 0; - while (completed < maxEffects && (yield* runOnce())) { - completed += 1; - } - return completed; - }), + drain: Effect.fn("EffectWorker.drain")(function* (maxEffects = Number.MAX_SAFE_INTEGER) { + let completed = 0; + while (completed < maxEffects && (yield* runOnce())) { + completed += 1; + } + return completed; + }), }); }), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 08048e1328f4..0a0299ca86ff 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -917,11 +917,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); - const makeEvent = ( - command: OrchestrationV2ServerCommand, - event: Omit, - ) => - Effect.gen(function* () { + const makeEvent = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* ( + command: OrchestrationV2ServerCommand, + event: Omit, + ) { const eventId = yield* mapDispatchError(command)( idAllocator.allocate.event({ threadId: event.threadId, @@ -993,11 +994,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), ); - const getProjectionWithPendingEvents = ( - threadId: ThreadId, - events: Ref.Ref>, - ) => - Effect.gen(function* () { + const getProjectionWithPendingEvents = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* ( + threadId: ThreadId, + events: Ref.Ref>, + ) { const pending = (yield* Ref.get(events)).filter((event) => event.threadId === threadId); const stored = yield* Effect.option(readCommandProjection(threadId)); let projection: OrchestrationV2ThreadProjection; @@ -1026,8 +1028,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return projection; }); - const makeSystemEvent = (event: Omit) => - Effect.gen(function* () { + const makeSystemEvent = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* ( + event: Omit, + ) { const eventId = yield* idAllocator.allocate.event({ threadId: event.threadId, }); @@ -1037,11 +1042,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } as Event; }); - const writeSystemEvents = ( - events: ReadonlyArray>, - effects: ReadonlyArray = [], - ) => - Effect.gen(function* () { + const writeSystemEvents = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* ( + events: ReadonlyArray>, + effects: ReadonlyArray = [], + ) { const withIds = yield* Effect.forEach(events, (event) => makeSystemEvent(event as Omit), ); @@ -1065,8 +1071,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? undefined : projection.messages.find((candidate) => candidate.id === delivery.messageId); - const offerDelegatedCompletionDelivery = (threadId: ThreadId, parentRunId: RunId) => - Effect.gen(function* () { + const offerDelegatedCompletionDelivery = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (threadId: ThreadId, parentRunId: RunId) { const projection = yield* projectionStore.getThreadRecords( threadId, ["runs", "messages", "providerTurns", "providerThreads"], @@ -1111,8 +1118,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - const offerDelegatedCompletionDeliveries = (threadId: ThreadId) => - Effect.gen(function* () { + const offerDelegatedCompletionDeliveries = + /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ + Effect.fnUntraced(function* (threadId: ThreadId) { const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"]); for (const run of projection.runs) { if ( @@ -1124,14 +1132,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); - const emitQueuedRunCancellation = (input: { - readonly command: OrchestrationV2ServerCommand; - readonly events: Ref.Ref>; - readonly projection: Pick; - readonly run: OrchestrationV2Run; - readonly now: DateTime.Utc; - }) => - Effect.gen(function* () { + const emitQueuedRunCancellation = Effect.fn("Orchestrator.emitQueuedRunCancellation")( + function* (input: { + readonly command: OrchestrationV2ServerCommand; + readonly events: Ref.Ref>; + readonly projection: Pick; + readonly run: OrchestrationV2Run; + readonly now: DateTime.Utc; + }) { const rootNode = input.run.rootNodeId === null ? undefined @@ -1187,118 +1195,121 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } - }); + }, + ); - const failQueuedRunStart = (threadId: ThreadId, cause: unknown) => - Effect.gen(function* () { - const projection = yield* projectionStore.getThreadRecords( - threadId, - ["runs", "nodes", "attempts", "providerThreads", "turnItems", "messages"], - { turnItemTypes: [], messageRoles: ["user"] }, - ); - const queuedRun = nextQueuedRun(projection); - if (queuedRun === undefined) return; - const now = yield* DateTime.now; - const rootNode = projection.nodes.find((node) => node.id === queuedRun.rootNodeId); - const attempt = projection.attempts.find((entry) => entry.id === queuedRun.activeAttemptId); - const providerThread = projection.providerThreads.find( - (entry) => entry.id === queuedRun.providerThreadId, - ); - const handoffUnsupported = - typeof cause === "object" && - cause !== null && - "_tag" in cause && - cause._tag === "CommandPolicyCapabilityUnsupportedError"; - const failureCause = - typeof cause === "object" && - cause !== null && - "_tag" in cause && - cause._tag === "OrchestratorDispatchError" && - "cause" in cause - ? cause.cause - : cause; - const failure = makeProviderFailure({ - cause: failureCause, - code: handoffUnsupported ? "context_handoff_unsupported" : "queued_start_failed", - class: handoffUnsupported ? "validation_error" : "unknown", - }); - yield* writeSystemEvents([ - ...(attempt !== undefined && rootNode !== undefined - ? [ - { - type: "run-attempt.updated" as const, - threadId, - runId: queuedRun.id, - nodeId: rootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { ...attempt, status: "failed" as const, completedAt: now }, - }, - ] - : []), - ...(rootNode === undefined - ? [] - : [ - { - type: "node.updated" as const, - threadId, - runId: queuedRun.id, - nodeId: rootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { ...rootNode, status: "failed" as const, completedAt: now }, - }, - ]), - ...(rootNode === undefined || providerThread === undefined - ? [] - : [ - { - type: "turn-item.updated" as const, + const failQueuedRunStart = Effect.fn("Orchestrator.failQueuedRunStart")(function* ( + threadId: ThreadId, + cause: unknown, + ) { + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "nodes", "attempts", "providerThreads", "turnItems", "messages"], + { turnItemTypes: [], messageRoles: ["user"] }, + ); + const queuedRun = nextQueuedRun(projection); + if (queuedRun === undefined) return; + const now = yield* DateTime.now; + const rootNode = projection.nodes.find((node) => node.id === queuedRun.rootNodeId); + const attempt = projection.attempts.find((entry) => entry.id === queuedRun.activeAttemptId); + const providerThread = projection.providerThreads.find( + (entry) => entry.id === queuedRun.providerThreadId, + ); + const handoffUnsupported = + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "CommandPolicyCapabilityUnsupportedError"; + const failureCause = + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "OrchestratorDispatchError" && + "cause" in cause + ? cause.cause + : cause; + const failure = makeProviderFailure({ + cause: failureCause, + code: handoffUnsupported ? "context_handoff_unsupported" : "queued_start_failed", + class: handoffUnsupported ? "validation_error" : "unknown", + }); + yield* writeSystemEvents([ + ...(attempt !== undefined && rootNode !== undefined + ? [ + { + type: "run-attempt.updated" as const, + threadId, + runId: queuedRun.id, + nodeId: rootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { ...attempt, status: "failed" as const, completedAt: now }, + }, + ] + : []), + ...(rootNode === undefined + ? [] + : [ + { + type: "node.updated" as const, + threadId, + runId: queuedRun.id, + nodeId: rootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "failed" as const, completedAt: now }, + }, + ]), + ...(rootNode === undefined || providerThread === undefined + ? [] + : [ + { + type: "turn-item.updated" as const, + threadId, + runId: queuedRun.id, + nodeId: rootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: providerThread.driver, + nativeItemId: `queued-start-failure:${queuedRun.id}`, + }), threadId, runId: queuedRun.id, nodeId: rootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - id: idAllocator.derive.turnItemFromProviderItem({ - driver: providerThread.driver, - nativeItemId: `queued-start-failure:${queuedRun.id}`, - }), - threadId, - runId: queuedRun.id, - nodeId: rootNode.id, - providerThreadId: providerThread.id, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: yield* nextTurnItemOrdinal(projection), - status: "failed" as const, - title: "Queued provider could not start", - startedAt: now, - completedAt: now, - updatedAt: now, - type: "error" as const, - failure, - }, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal(projection), + status: "failed" as const, + title: "Queued provider could not start", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "error" as const, + failure, }, - ]), - { - type: "run.updated", - threadId, - runId: queuedRun.id, - ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { ...queuedRun, status: "failed", queuePosition: null, completedAt: now }, - }, - ]); - }); + }, + ]), + { + type: "run.updated", + threadId, + runId: queuedRun.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { ...queuedRun, status: "failed", queuePosition: null, completedAt: now }, + }, + ]); + }); - const startNextQueuedRun = ( - threadId: ThreadId, - options?: { readonly failedRunId?: RunId; readonly endedRunId?: RunId }, - ) => - Effect.gen(function* () { + const startNextQueuedRun = Effect.fn("Orchestrator.startNextQueuedRun")( + function* ( + threadId: ThreadId, + options?: { readonly failedRunId?: RunId; readonly endedRunId?: RunId }, + ) { if (!(yield* projectionStore.canStartQueuedRun(threadId))) return; const projection = yield* readCommandProjection(threadId); if ( @@ -1934,7 +1945,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ], ); - }).pipe(Effect.catch((cause) => failQueuedRunStart(threadId, cause))); + }, + (effect, threadId, _options?) => + effect.pipe(Effect.catch((cause) => failQueuedRunStart(threadId, cause))), + ); const resumeQueuedRuns = Effect.gen(function* () { const threadIds = yield* projectionStore.getRecoveryThreadIds("queued-runs"); @@ -1968,7 +1982,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); - const dispatchDelegatedTaskCompletionDeliveryResolution = ( + const dispatchDelegatedTaskCompletionDeliveryResolution = Effect.fn( + "Orchestrator.dispatchDelegatedTaskCompletionDeliveryResolution", + )(function* ( command: Extract< OrchestrationV2Command, { @@ -1978,71 +1994,47 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } >, events: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* projectionStore - .getThreadRecords( - command.parentThreadId, - ["subagents", "runs", "messages", "nodes", "attempts", "turnItems"], - { turnItemTypes: [], messageRoles: ["user"] }, - ) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorProjectionError({ - threadId: command.parentThreadId, - cause, - }), - ), - ); - const task = projection.subagents.find( - (candidate) => candidate.id === command.taskId && candidate.origin === "app_owned", + ) { + const projection = yield* projectionStore + .getThreadRecords( + command.parentThreadId, + ["subagents", "runs", "messages", "nodes", "attempts", "turnItems"], + { turnItemTypes: [], messageRoles: ["user"] }, + ) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ + threadId: command.parentThreadId, + cause, + }), + ), ); - if (task === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Delegated task ${command.taskId} is not an app-owned task of thread ${command.parentThreadId}.`, - }); - } - const state = - command.type === "delegated_task.completion-delivery.acknowledge" - ? "acknowledged" - : "disposed"; - const now = yield* DateTime.now; - const emitEvent = emit(events, command); - // task_status and t3_thread_read use distinct command IDs, so two - // valid observations can race after their read preflight. Re-emit the - // existing task row so the second dispatch is a successful idempotent - // no-op rather than "already acknowledged/disposed" or empty-events. - if ( - task.completionDelivery?.state === state || - (command.type === "delegated_task.completion-delivery.acknowledge" && - task.completionDelivery?.state === "disposed") - ) { - yield* emitEvent({ - type: "subagent.updated", - threadId: command.parentThreadId, - ...(task.runId === null ? {} : { runId: task.runId }), - nodeId: task.id, - driver: task.driver, - providerInstanceId: task.providerInstanceId, - occurredAt: now, - payload: task, - }); - return; - } - const updatedTask: OrchestrationV2Subagent = { - ...task, - completionDelivery: { - state, - observedByRunId: - command.type === "delegated_task.completion-delivery.acknowledge" - ? command.observedByRunId - : null, - }, - updatedAt: now, - }; + const task = projection.subagents.find( + (candidate) => candidate.id === command.taskId && candidate.origin === "app_owned", + ); + if (task === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Delegated task ${command.taskId} is not an app-owned task of thread ${command.parentThreadId}.`, + }); + } + const state = + command.type === "delegated_task.completion-delivery.acknowledge" + ? "acknowledged" + : "disposed"; + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + // task_status and t3_thread_read use distinct command IDs, so two + // valid observations can race after their read preflight. Re-emit the + // existing task row so the second dispatch is a successful idempotent + // no-op rather than "already acknowledged/disposed" or empty-events. + if ( + task.completionDelivery?.state === state || + (command.type === "delegated_task.completion-delivery.acknowledge" && + task.completionDelivery?.state === "disposed") + ) { yield* emitEvent({ type: "subagent.updated", threadId: command.parentThreadId, @@ -2051,90 +2043,115 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio driver: task.driver, providerInstanceId: task.providerInstanceId, occurredAt: now, - payload: updatedTask, + payload: task, }); + return; + } + const updatedTask: OrchestrationV2Subagent = { + ...task, + completionDelivery: { + state, + observedByRunId: + command.type === "delegated_task.completion-delivery.acknowledge" + ? command.observedByRunId + : null, + }, + updatedAt: now, + }; + yield* emitEvent({ + type: "subagent.updated", + threadId: command.parentThreadId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + providerInstanceId: task.providerInstanceId, + occurredAt: now, + payload: updatedTask, + }); - const parentRun = - task.runId === null - ? undefined - : projection.runs.find((candidate) => candidate.id === task.runId); - const cohort = parentRun?.delegatedCompletion; - const delivery = cohort?.delivery; - if ( - parentRun === undefined || - cohort === undefined || - delivery === null || - delivery === undefined - ) { + const parentRun = + task.runId === null + ? undefined + : projection.runs.find((candidate) => candidate.id === task.runId); + const cohort = parentRun?.delegatedCompletion; + const delivery = cohort?.delivery; + if ( + parentRun === undefined || + cohort === undefined || + delivery === null || + delivery === undefined + ) { + return; + } + if (!delivery.taskIds.includes(task.id)) { + return; + } + + const remainingTaskIds = delivery.taskIds.filter((taskId) => taskId !== task.id); + const deliveryRun = completionDeliveryRun(projection, delivery); + const clearDelivery = + remainingTaskIds.length === 0 && + (deliveryRun === undefined || deliveryRun.status === "queued"); + const updatedCohort: OrchestrationV2DelegatedCompletionCohort = { + ...cohort, + delivery: clearDelivery + ? null + : { + ...delivery, + taskIds: remainingTaskIds, + }, + }; + yield* emitEvent({ + type: "run.updated", + threadId: command.parentThreadId, + runId: parentRun.id, + ...(parentRun.rootNodeId === null ? {} : { nodeId: parentRun.rootNodeId }), + providerInstanceId: parentRun.providerInstanceId, + occurredAt: now, + payload: { + ...parentRun, + delegatedCompletion: updatedCohort, + }, + }); + + if (deliveryRun?.status === "queued") { + if (remainingTaskIds.length === 0) { + yield* emitQueuedRunCancellation({ + command, + events, + projection, + run: deliveryRun, + now, + }); return; } - if (!delivery.taskIds.includes(task.id)) { - return; - } - - const remainingTaskIds = delivery.taskIds.filter((taskId) => taskId !== task.id); - const deliveryRun = completionDeliveryRun(projection, delivery); - const clearDelivery = - remainingTaskIds.length === 0 && - (deliveryRun === undefined || deliveryRun.status === "queued"); - const updatedCohort: OrchestrationV2DelegatedCompletionCohort = { - ...cohort, - delivery: clearDelivery - ? null - : { - ...delivery, + const message = completionDeliveryMessage(projection, delivery); + if (message !== undefined) { + yield* emitEvent({ + type: "message.updated", + threadId: command.parentThreadId, + runId: deliveryRun.id, + ...(deliveryRun.rootNodeId === null ? {} : { nodeId: deliveryRun.rootNodeId }), + providerInstanceId: deliveryRun.providerInstanceId, + occurredAt: now, + payload: { + ...message, + text: delegatedCompletionWakeDetail(remainingTaskIds), + delegatedCompletion: { + parentRunId: parentRun.id, + generation: delivery.generation, taskIds: remainingTaskIds, }, - }; - yield* emitEvent({ - type: "run.updated", - threadId: command.parentThreadId, - runId: parentRun.id, - ...(parentRun.rootNodeId === null ? {} : { nodeId: parentRun.rootNodeId }), - providerInstanceId: parentRun.providerInstanceId, - occurredAt: now, - payload: { - ...parentRun, - delegatedCompletion: updatedCohort, - }, - }); - - if (deliveryRun?.status === "queued") { - if (remainingTaskIds.length === 0) { - yield* emitQueuedRunCancellation({ - command, - events, - projection, - run: deliveryRun, - now, - }); - return; - } - const message = completionDeliveryMessage(projection, delivery); - if (message !== undefined) { - yield* emitEvent({ - type: "message.updated", - threadId: command.parentThreadId, - runId: deliveryRun.id, - ...(deliveryRun.rootNodeId === null ? {} : { nodeId: deliveryRun.rootNodeId }), - providerInstanceId: deliveryRun.providerInstanceId, - occurredAt: now, - payload: { - ...message, - text: delegatedCompletionWakeDetail(remainingTaskIds), - delegatedCompletion: { - parentRunId: parentRun.id, - generation: delivery.generation, - taskIds: remainingTaskIds, - }, - updatedAt: now, - }, - }); - } + updatedAt: now, + }, + }); } - }); + } + }); - const disposeDelegatedCompletionCohort = (input: { + const disposeDelegatedCompletionCohort = Effect.fn( + "Orchestrator.disposeDelegatedCompletionCohort", + )(function* (input: { readonly command: OrchestrationV2ServerCommand; readonly events: Ref.Ref>; readonly projection: Pick< @@ -2145,77 +2162,74 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly disposition: "stopped" | "disposed"; readonly now: DateTime.Utc; readonly cancelQueuedDelivery?: boolean; - }) => - Effect.gen(function* () { - const parentRun = input.projection.runs.find( - (candidate) => candidate.id === input.parentRunId, - ); - if (parentRun === undefined) { - return; - } - const cohort = parentRun.delegatedCompletion; - const tasks = input.projection.subagents.filter( - (candidate) => candidate.origin === "app_owned" && candidate.runId === input.parentRunId, - ); - if (cohort === undefined && tasks.length === 0) { - return; + }) { + const parentRun = input.projection.runs.find((candidate) => candidate.id === input.parentRunId); + if (parentRun === undefined) { + return; + } + const cohort = parentRun.delegatedCompletion; + const tasks = input.projection.subagents.filter( + (candidate) => candidate.origin === "app_owned" && candidate.runId === input.parentRunId, + ); + if (cohort === undefined && tasks.length === 0) { + return; + } + const emitEvent = emit(input.events, input.command); + const nextDisposition = cohort?.disposition === "disposed" ? "disposed" : input.disposition; + const nextCohort = { + disposition: nextDisposition, + nextGeneration: cohort?.nextGeneration ?? 1, + delivery: null, + } as const; + yield* emitEvent({ + type: "run.updated", + threadId: parentRun.threadId, + runId: parentRun.id, + ...(parentRun.rootNodeId === null ? {} : { nodeId: parentRun.rootNodeId }), + providerInstanceId: parentRun.providerInstanceId, + occurredAt: input.now, + payload: { + ...parentRun, + delegatedCompletion: nextCohort, + }, + }); + for (const task of tasks) { + if ( + task.completionDelivery?.state === "acknowledged" || + task.completionDelivery?.state === "delivered" || + task.completionDelivery?.state === "disposed" + ) { + continue; } - const emitEvent = emit(input.events, input.command); - const nextDisposition = cohort?.disposition === "disposed" ? "disposed" : input.disposition; - const nextCohort = { - disposition: nextDisposition, - nextGeneration: cohort?.nextGeneration ?? 1, - delivery: null, - } as const; yield* emitEvent({ - type: "run.updated", + type: "subagent.updated", threadId: parentRun.threadId, - runId: parentRun.id, - ...(parentRun.rootNodeId === null ? {} : { nodeId: parentRun.rootNodeId }), - providerInstanceId: parentRun.providerInstanceId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + providerInstanceId: task.providerInstanceId, occurredAt: input.now, payload: { - ...parentRun, - delegatedCompletion: nextCohort, + ...task, + completionDelivery: { + state: "disposed", + observedByRunId: null, + }, + updatedAt: input.now, }, }); - for (const task of tasks) { - if ( - task.completionDelivery?.state === "acknowledged" || - task.completionDelivery?.state === "delivered" || - task.completionDelivery?.state === "disposed" - ) { - continue; - } - yield* emitEvent({ - type: "subagent.updated", - threadId: parentRun.threadId, - ...(task.runId === null ? {} : { runId: task.runId }), - nodeId: task.id, - driver: task.driver, - providerInstanceId: task.providerInstanceId, - occurredAt: input.now, - payload: { - ...task, - completionDelivery: { - state: "disposed", - observedByRunId: null, - }, - updatedAt: input.now, - }, - }); - } - const deliveryRun = completionDeliveryRun(input.projection, cohort?.delivery ?? null); - if (input.cancelQueuedDelivery !== false && deliveryRun?.status === "queued") { - yield* emitQueuedRunCancellation({ - command: input.command, - events: input.events, - projection: input.projection, - run: deliveryRun, - now: input.now, - }); - } - }); + } + const deliveryRun = completionDeliveryRun(input.projection, cohort?.delivery ?? null); + if (input.cancelQueuedDelivery !== false && deliveryRun?.status === "queued") { + yield* emitQueuedRunCancellation({ + command: input.command, + events: input.events, + projection: input.projection, + run: deliveryRun, + now: input.now, + }); + } + }); const disposeAllDelegatedCompletionCohorts = (input: { readonly command: OrchestrationV2Command; @@ -3903,7 +3917,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - const dispatchSteerIntoRun = (input: { + const dispatchSteerIntoRun = Effect.fn("Orchestrator.dispatchSteerIntoRun")(function* (input: { readonly command: Extract< OrchestrationV2Command, { readonly type: "message.dispatch" | "queued-message.promote-to-steer" } @@ -3935,593 +3949,251 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly senderThreadId?: OrchestrationV2ConversationMessage["senderThreadId"]; readonly delegatedCompletion?: OrchestrationV2ConversationMessage["delegatedCompletion"]; readonly forceRestart: boolean; - }) => - Effect.gen(function* () { - const targetRun = input.projection.runs.find( - (candidate) => candidate.id === input.targetRunId, - ); - if (targetRun === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `Target run ${input.targetRunId} was not found.`, - }); - } - if (isNativeMaintenanceCommand(input)) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: - input.text.trim().toLowerCase() === "/compact" - ? "Context compaction must run as a separate turn. Queue it or wait for the active turn to finish." - : "Signing out must run as a separate turn. Queue it or wait for the active turn to finish.", - }); - } - if (isGoalCommand(input)) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: - "Goal commands must run as a separate turn. Queue it or wait for the active turn to finish.", - }); - } - const targetMessage = input.projection.messages.find( - (message) => message.id === targetRun.userMessageId, - ); - if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: - targetMessage.text.trim().toLowerCase() === "/compact" - ? "Wait for context compaction to finish before steering the thread." - : "Wait for sign-out to finish before steering the thread.", - }); - } - const rootNodeId = targetRun.rootNodeId; - if (rootNodeId === null) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `Target run ${targetRun.id} has no root node.`, - }); - } - if (targetRun.status !== "running") { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `Target run ${targetRun.id} is ${targetRun.status} and cannot be steered.`, - }); - } - // The run reads as running until its provider stops, but a steer accepted - // after Stop never reaches the agent. - if (yield* stopReachedRun(input.command, input.command.threadId, targetRun.id)) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `Target run ${targetRun.id} is stopping and cannot be steered.`, - }); - } - const providerThread = input.projection.providerThreads.find( - (candidate) => candidate.id === targetRun.providerThreadId, - ); - if (providerThread === undefined || providerThread.providerSessionId === null) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `Provider thread ${targetRun.providerThreadId} has no active provider session for steering.`, - }); - } - const providerSessionId = providerThread.providerSessionId; - const providerTurn = input.projection.providerTurns.find( - (candidate) => - candidate.runAttemptId === targetRun.activeAttemptId && candidate.status === "running", - ); - if (providerTurn === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `No running provider turn found for active run ${targetRun.id}.`, - }); - } - const sessionOption = yield* providerSessions.get(providerSessionId).pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause, - }), - ), - ); - if (Option.isNone(sessionOption)) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `Provider session ${providerThread.providerSessionId} is not active.`, - }); - } + }) { + const targetRun = input.projection.runs.find((candidate) => candidate.id === input.targetRunId); + if (targetRun === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `Target run ${input.targetRunId} was not found.`, + }); + } + if (isNativeMaintenanceCommand(input)) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: + input.text.trim().toLowerCase() === "/compact" + ? "Context compaction must run as a separate turn. Queue it or wait for the active turn to finish." + : "Signing out must run as a separate turn. Queue it or wait for the active turn to finish.", + }); + } + if (isGoalCommand(input)) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: + "Goal commands must run as a separate turn. Queue it or wait for the active turn to finish.", + }); + } + const targetMessage = input.projection.messages.find( + (message) => message.id === targetRun.userMessageId, + ); + if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: + targetMessage.text.trim().toLowerCase() === "/compact" + ? "Wait for context compaction to finish before steering the thread." + : "Wait for sign-out to finish before steering the thread.", + }); + } + const rootNodeId = targetRun.rootNodeId; + if (rootNodeId === null) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `Target run ${targetRun.id} has no root node.`, + }); + } + if (targetRun.status !== "running") { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `Target run ${targetRun.id} is ${targetRun.status} and cannot be steered.`, + }); + } + // The run reads as running until its provider stops, but a steer accepted + // after Stop never reaches the agent. + if (yield* stopReachedRun(input.command, input.command.threadId, targetRun.id)) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `Target run ${targetRun.id} is stopping and cannot be steered.`, + }); + } + const providerThread = input.projection.providerThreads.find( + (candidate) => candidate.id === targetRun.providerThreadId, + ); + if (providerThread === undefined || providerThread.providerSessionId === null) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `Provider thread ${targetRun.providerThreadId} has no active provider session for steering.`, + }); + } + const providerSessionId = providerThread.providerSessionId; + const providerTurn = input.projection.providerTurns.find( + (candidate) => + candidate.runAttemptId === targetRun.activeAttemptId && candidate.status === "running", + ); + if (providerTurn === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `No running provider turn found for active run ${targetRun.id}.`, + }); + } + const sessionOption = yield* providerSessions.get(providerSessionId).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause, + }), + ), + ); + if (Option.isNone(sessionOption)) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `Provider session ${providerThread.providerSessionId} is not active.`, + }); + } - const session = sessionOption.value; - const now = yield* DateTime.now; - const emitEvent = emit(input.events, input.command); - const selectionChanged = !modelSelectionsEqual( - targetRun.modelSelection, - input.modelSelection, - ); - const providerInstanceChanged = - targetRun.providerInstanceId !== input.modelSelection.instanceId; - const selectionTransition = - selectionChanged && !providerInstanceChanged - ? yield* providerAdapters.get(targetRun.providerInstanceId).pipe( - Effect.flatMap((adapter) => - adapter.planSelectionTransition({ - current: targetRun.modelSelection, - target: input.modelSelection, - sessionCapabilities: session.providerSession.capabilities, + const session = sessionOption.value; + const now = yield* DateTime.now; + const emitEvent = emit(input.events, input.command); + const selectionChanged = !modelSelectionsEqual(targetRun.modelSelection, input.modelSelection); + const providerInstanceChanged = + targetRun.providerInstanceId !== input.modelSelection.instanceId; + const selectionTransition = + selectionChanged && !providerInstanceChanged + ? yield* providerAdapters.get(targetRun.providerInstanceId).pipe( + Effect.flatMap((adapter) => + adapter.planSelectionTransition({ + current: targetRun.modelSelection, + target: input.modelSelection, + sessionCapabilities: session.providerSession.capabilities, + }), + ), + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: input.command.commandId, + providerInstanceId: targetRun.providerInstanceId, + cause, }), - ), - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: input.command.commandId, - providerInstanceId: targetRun.providerInstanceId, - cause, - }), - ), - ) - : null; - if (selectionTransition?.type === "reject") { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: selectionTransition.reason, - }); - } - const appendSteeringMessage = (messageInput: { + ), + ) + : null; + if (selectionTransition?.type === "reject") { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: selectionTransition.reason, + }); + } + const appendSteeringMessage = Effect.fn("Orchestrator.appendSteeringMessage")( + function* (messageInput: { readonly runId: OrchestrationV2Run["id"]; readonly nodeId: OrchestrationV2ExecutionNode["id"]; readonly providerTurnId: typeof providerTurn.id | null; readonly providerThreadId: OrchestrationV2ProviderThread["id"]; readonly providerInstanceId: ProviderInstanceId; - }) => - Effect.gen(function* () { - const message: OrchestrationV2ConversationMessage = { - createdBy: input.createdBy, - creationSource: input.creationSource, - ...(input.delegatedCompletion === undefined - ? {} - : { delegatedCompletion: input.delegatedCompletion }), - ...(input.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: input.scheduledTaskId }), - ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), - id: input.messageId, - threadId: input.command.threadId, - runId: messageInput.runId, - nodeId: messageInput.nodeId, - role: "user", - text: input.text, - attachments: input.attachments, - ...(input.context ? { context: input.context } : {}), - streaming: false, - createdAt: now, - updatedAt: now, - }; - const turnItem: OrchestrationV2TurnItem = { - createdBy: input.createdBy, - creationSource: input.creationSource, - ...(input.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: input.scheduledTaskId }), - ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), - id: idAllocator.derive.userTurnItem({ messageId: input.messageId }), - threadId: input.command.threadId, - runId: messageInput.runId, - nodeId: messageInput.nodeId, - providerThreadId: messageInput.providerThreadId, - providerTurnId: messageInput.providerTurnId, - nativeItemRef: null, - parentItemId: null, - ordinal: yield* nextTurnItemOrdinal(input.projection), - status: "completed", - title: null, - startedAt: now, - completedAt: now, - updatedAt: now, - type: "user_message", - messageId: input.messageId, - inputIntent: - input.command.type === "queued-message.promote-to-steer" - ? "promoted_queued_to_steer" - : "steer", - text: input.text, - attachments: input.attachments, - ...(input.context ? { context: input.context } : {}), - }; - yield* emitEvent({ - type: "message.updated", - threadId: input.command.threadId, - runId: messageInput.runId, - nodeId: messageInput.nodeId, - providerInstanceId: messageInput.providerInstanceId, - occurredAt: now, - payload: message, - }); - yield* emitEvent({ - type: "turn-item.updated", - threadId: input.command.threadId, - runId: messageInput.runId, - nodeId: messageInput.nodeId, - providerInstanceId: messageInput.providerInstanceId, - occurredAt: now, - payload: notificationTurnItem(turnItem, message, input.projection.subagents), - }); - }); - - // A selection the provider applies on its next turn joins the running - // turn as a steer and waits for the next turn. Restarting the turn for it - // would end the turn's native subagents, which a steer must never do. - const selectionMustApplyNow = - selectionChanged && - (providerInstanceChanged || selectionTransition?.type !== "apply_on_next_turn"); - const steeringPolicy = yield* enforceCommandPolicy(input.command)( - commandPolicy.decideSteeringExecution({ - commandId: input.command.commandId, - threadId: input.command.threadId, - providerInstanceId: targetRun.providerInstanceId, - capabilities: session.providerSession.capabilities, - forceRestart: input.forceRestart || selectionMustApplyNow, - }), - ); - - if (steeringPolicy === "active_steering") { - const instanceChanged = - input.projection.thread.providerInstanceId !== input.modelSelection.instanceId; - if ( - input.delegatedCompletion === undefined && - (instanceChanged || - !modelSelectionsEqual(input.projection.thread.modelSelection, input.modelSelection)) - ) { - yield* emitEvent({ - type: instanceChanged ? "thread.provider-switched" : "thread.model-selection-updated", - threadId: input.command.threadId, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: { - ...input.projection.thread, - providerInstanceId: input.modelSelection.instanceId, - modelSelection: input.modelSelection, - updatedAt: now, - }, - }); - } - yield* appendSteeringMessage({ - runId: targetRun.id, - nodeId: rootNodeId, - providerTurnId: providerTurn.id, - providerThreadId: providerThread.id, - providerInstanceId: targetRun.providerInstanceId, - }); - yield* Ref.update(input.effects, (existing) => [ - ...existing, - { - id: `effect:${input.command.commandId}:provider-turn.steer:${providerTurn.id}`, - commandId: input.command.commandId, - threadId: input.command.threadId, - request: { - type: "provider-turn.steer", - providerSessionId, - providerThreadId: providerThread.id, - providerTurnId: providerTurn.id, - messageId: input.messageId, - }, - } satisfies PendingOrchestrationEffectV2, - ]); - return; - } - - const currentAttempt = input.projection.attempts.find( - (candidate) => candidate.id === targetRun.activeAttemptId, - ); - const currentRootNode = input.projection.nodes.find( - (candidate) => candidate.id === rootNodeId, - ); - const attemptOrdinal = - Math.max( - 0, - ...input.projection.attempts - .filter((candidate) => candidate.runId === targetRun.id) - .map((candidate) => candidate.attemptOrdinal), - ) + 1; - const nextAttemptId = idAllocator.derive.runAttempt({ - runId: targetRun.id, - attemptOrdinal, - }); - const nextRootNodeId = idAllocator.derive.rootNodeAttempt({ - runId: targetRun.id, - attemptOrdinal, - }); - let restartProviderThread = providerThread; - let restartSessionTransition: - | { - readonly type: "replace"; - readonly replacementProviderSessionId: ProviderSessionId; - } - | { readonly type: "detach" } - | null = null; - let restartHandoff: OrchestrationV2ContextHandoff | null = null; - let restartTransfer: OrchestrationV2ContextTransfer | null = null; - const canResumeAcrossInstances = - providerInstanceChanged && - providerThread.nativeThreadRef !== null && - (yield* providerSwitchService - .plan({ - projection: { - ...input.projection, - thread: { ...input.projection.thread, modelSelection: targetRun.modelSelection }, - }, - targetModelSelection: input.modelSelection, - }) - .pipe(mapDispatchError(input.command))).transition.type === "restart_and_resume"; - const requiresProviderThreadHandoff = - (providerInstanceChanged && !canResumeAcrossInstances) || - selectionTransition?.type === "create_with_handoff"; - const requiresProviderSessionRestart = - canResumeAcrossInstances || selectionTransition?.type === "restart_session"; - if (requiresProviderThreadHandoff) { - const targetAdapter = yield* providerAdapters.get(input.modelSelection.instanceId).pipe( - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: input.command.commandId, - providerInstanceId: input.modelSelection.instanceId, - cause, - }), - ), - ); - const targetCapabilities = yield* targetAdapter.getCapabilities().pipe( - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: input.command.commandId, - providerInstanceId: input.modelSelection.instanceId, - cause, - }), - ), - ); - yield* enforceCommandPolicy(input.command)( - commandPolicy.ensureContextHandoff({ - commandId: input.command.commandId, - threadId: input.command.threadId, - providerInstanceId: input.modelSelection.instanceId, - capabilities: targetCapabilities, - strategy: "full_thread_summary", - }), - ); - const existingTargetProviderThread = rootProviderThreadsForProvider( - input.projection, - input.modelSelection.instanceId, - ).find((candidate) => candidate.id !== providerThread.id); - const targetProviderSessionId = - existingTargetProviderThread?.providerSessionId ?? - (yield* mapDispatchError(input.command)( - providerSessionIdFor({ - adapter: targetAdapter, - providerInstanceId: input.modelSelection.instanceId, - threadId: input.command.threadId, - }), - )); - const targetProviderThreadBase: OrchestrationV2ProviderThread = - existingTargetProviderThread === undefined - ? { - id: idAllocator.derive.providerThread({ - driver: targetAdapter.driver, - nativeThreadId: `pending:${targetRun.id}:attempt:${attemptOrdinal}`, - }), - driver: targetAdapter.driver, - providerInstanceId: input.modelSelection.instanceId, - providerSessionId: targetProviderSessionId, - appThreadId: input.command.threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "not_loaded", - firstRunOrdinal: targetRun.ordinal, - lastRunOrdinal: targetRun.ordinal, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - } - : { - ...existingTargetProviderThread, - providerSessionId: targetProviderSessionId, - lastRunOrdinal: targetRun.ordinal, - updatedAt: now, - }; - const transferId = yield* mapDispatchError(input.command)( - idAllocator.allocate.contextTransfer({ - sourceThreadId: input.command.threadId, - targetThreadId: input.command.threadId, - type: "provider_handoff", - }), - ); - restartHandoff = yield* contextHandoffService - .prepareProviderHandoff({ - threadId: input.command.threadId, - targetRunId: targetRun.id, - transferId, - fromProviderThreadIds: [providerThread.id], - toProviderThreadId: targetProviderThreadBase.id, - fromProviderInstanceId: targetRun.providerInstanceId, - toProviderInstanceId: input.modelSelection.instanceId, - coveredRunOrdinals: { from: 1, to: targetRun.ordinal }, - strategy: "full_thread_summary", - items: yield* readHandoffItems(input.command.threadId), - runs: input.projection.runs, - createdAt: now, - }) - .pipe(mapDispatchError(input.command)); - restartProviderThread = { - ...targetProviderThreadBase, - handoffIds: Array.from( - new Set([...targetProviderThreadBase.handoffIds, restartHandoff.id]), - ), - }; - restartTransfer = { - id: transferId, - type: "provider_handoff", - sourceThreadId: input.command.threadId, - targetThreadId: input.command.threadId, - sourcePoint: contextSourcePointForRun(input.projection, targetRun), - basePoint: null, - sourceProviderInstanceId: targetRun.providerInstanceId, - targetProviderInstanceId: input.modelSelection.instanceId, - targetRunId: targetRun.id, - status: "consumed", - resolution: { - strategy: "portable_context", - contextHandoffId: restartHandoff.id, - }, + }) { + const message: OrchestrationV2ConversationMessage = { createdBy: input.createdBy, - error: null, + creationSource: input.creationSource, + ...(input.delegatedCompletion === undefined + ? {} + : { delegatedCompletion: input.delegatedCompletion }), + ...(input.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: input.scheduledTaskId }), + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + id: input.messageId, + threadId: input.command.threadId, + runId: messageInput.runId, + nodeId: messageInput.nodeId, + role: "user", + text: input.text, + attachments: input.attachments, + ...(input.context ? { context: input.context } : {}), + streaming: false, createdAt: now, updatedAt: now, - consumedAt: now, }; - restartSessionTransition = { type: "detach" }; - } else if (requiresProviderSessionRestart) { - const nextProviderSessionId = yield* mapDispatchError(input.command)( - idAllocator.allocate.providerSession({ - providerInstanceId: input.modelSelection.instanceId, - threadId: input.command.threadId, - }), - ); - restartProviderThread = { - ...providerThread, - providerInstanceId: input.modelSelection.instanceId, - providerSessionId: nextProviderSessionId, - status: "not_loaded", + const turnItem: OrchestrationV2TurnItem = { + createdBy: input.createdBy, + creationSource: input.creationSource, + ...(input.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: input.scheduledTaskId }), + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + id: idAllocator.derive.userTurnItem({ messageId: input.messageId }), + threadId: input.command.threadId, + runId: messageInput.runId, + nodeId: messageInput.nodeId, + providerThreadId: messageInput.providerThreadId, + providerTurnId: messageInput.providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal(input.projection), + status: "completed", + title: null, + startedAt: now, + completedAt: now, updatedAt: now, + type: "user_message", + messageId: input.messageId, + inputIntent: + input.command.type === "queued-message.promote-to-steer" + ? "promoted_queued_to_steer" + : "steer", + text: input.text, + attachments: input.attachments, + ...(input.context ? { context: input.context } : {}), }; - restartSessionTransition = { - type: "replace", - replacementProviderSessionId: nextProviderSessionId, - }; - } - const resolvedRuntimePolicy = yield* runtimePolicy - .resolve({ thread: input.projection.thread, modelSelection: input.modelSelection }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause, - }), - ), - ); - const checkpointScope = yield* checkpointService - .prepareRootRunScope({ - threadId: input.command.threadId, - runId: targetRun.id, - rootNodeId: nextRootNodeId, - providerThreadId: restartProviderThread.id, - cwd: - resolvedRuntimePolicy.cwd ?? - input.projection.thread.worktreePath ?? - session.providerSession.cwd, - createdAt: now, - }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause, - }), - ), - ); - const ensuredCheckpointScope = yield* checkpointService.ensureScope(checkpointScope).pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause, - }), - ), - ); - const restartedRun: OrchestrationV2Run = { - ...targetRun, - providerInstanceId: input.modelSelection.instanceId, - modelSelection: input.modelSelection, - providerThreadId: restartProviderThread.id, - rootNodeId: nextRootNodeId, - activeAttemptId: nextAttemptId, - userMessageId: input.messageId, - status: "starting", - contextHandoffId: restartHandoff?.id ?? targetRun.contextHandoffId, - }; - const nextAttempt: OrchestrationV2RunAttempt = { - id: nextAttemptId, - runId: targetRun.id, - attemptOrdinal, - rootNodeId: nextRootNodeId, - providerInstanceId: input.modelSelection.instanceId, - providerThreadId: restartProviderThread.id, - providerTurnId: null, - reason: "steering_restart", - status: "pending", - startedAt: null, - completedAt: null, - }; - const nextRootNode: OrchestrationV2ExecutionNode = { - id: nextRootNodeId, - threadId: input.command.threadId, - runId: targetRun.id, - parentNodeId: null, - rootNodeId: nextRootNodeId, - kind: "root_turn", - status: "pending", - countsForRun: true, - providerThreadId: restartProviderThread.id, - providerTurnId: null, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: ensuredCheckpointScope.id, - startedAt: null, - completedAt: null, - }; - if (currentAttempt !== undefined) { yield* emitEvent({ - type: "run-attempt.updated", + type: "message.updated", threadId: input.command.threadId, - runId: targetRun.id, - nodeId: rootNodeId, - providerInstanceId: targetRun.providerInstanceId, + runId: messageInput.runId, + nodeId: messageInput.nodeId, + providerInstanceId: messageInput.providerInstanceId, occurredAt: now, - payload: { ...currentAttempt, status: "superseded", completedAt: now }, + payload: message, }); - } - if (currentRootNode !== undefined) { yield* emitEvent({ - type: "node.updated", + type: "turn-item.updated", threadId: input.command.threadId, - runId: targetRun.id, - nodeId: rootNodeId, - providerInstanceId: targetRun.providerInstanceId, + runId: messageInput.runId, + nodeId: messageInput.nodeId, + providerInstanceId: messageInput.providerInstanceId, occurredAt: now, - payload: { ...currentRootNode, status: "interrupted", completedAt: now }, + payload: notificationTurnItem(turnItem, message, input.projection.subagents), }); - } - if (selectionChanged) { + }, + ); + + // A selection the provider applies on its next turn joins the running + // turn as a steer and waits for the next turn. Restarting the turn for it + // would end the turn's native subagents, which a steer must never do. + const selectionMustApplyNow = + selectionChanged && + (providerInstanceChanged || selectionTransition?.type !== "apply_on_next_turn"); + const steeringPolicy = yield* enforceCommandPolicy(input.command)( + commandPolicy.decideSteeringExecution({ + commandId: input.command.commandId, + threadId: input.command.threadId, + providerInstanceId: targetRun.providerInstanceId, + capabilities: session.providerSession.capabilities, + forceRestart: input.forceRestart || selectionMustApplyNow, + }), + ); + + if (steeringPolicy === "active_steering") { + const instanceChanged = + input.projection.thread.providerInstanceId !== input.modelSelection.instanceId; + if ( + input.delegatedCompletion === undefined && + (instanceChanged || + !modelSelectionsEqual(input.projection.thread.modelSelection, input.modelSelection)) + ) { yield* emitEvent({ - type: providerInstanceChanged - ? "thread.provider-switched" - : "thread.model-selection-updated", + type: instanceChanged ? "thread.provider-switched" : "thread.model-selection-updated", threadId: input.command.threadId, providerInstanceId: input.modelSelection.instanceId, occurredAt: now, @@ -4533,823 +4205,1008 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } - if (requiresProviderThreadHandoff || requiresProviderSessionRestart) { - yield* emitEvent({ - type: "provider-thread.updated", - threadId: input.command.threadId, - driver: restartProviderThread.driver, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: restartProviderThread, - }); - } - if (restartHandoff !== null) { - yield* emitEvent({ - type: "context-handoff.updated", - threadId: input.command.threadId, - runId: targetRun.id, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: restartHandoff, - }); - } - if (restartTransfer !== null) { - yield* emitEvent({ - type: "context-transfer.created", - threadId: input.command.threadId, - runId: targetRun.id, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: restartTransfer, - }); - } - yield* emitEvent({ - type: "run.updated", - threadId: input.command.threadId, - runId: targetRun.id, - nodeId: nextRootNodeId, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: restartedRun, - }); - yield* emitEvent({ - type: "run-attempt.created", - threadId: input.command.threadId, - runId: targetRun.id, - nodeId: nextRootNodeId, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: nextAttempt, - }); - yield* emitEvent({ - type: "node.updated", - threadId: input.command.threadId, - runId: targetRun.id, - nodeId: nextRootNodeId, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: nextRootNode, - }); - yield* emitEvent({ - type: "checkpoint-scope.created", - threadId: input.command.threadId, - runId: targetRun.id, - nodeId: nextRootNodeId, - providerInstanceId: input.modelSelection.instanceId, - occurredAt: now, - payload: ensuredCheckpointScope, - }); yield* appendSteeringMessage({ runId: targetRun.id, - nodeId: nextRootNodeId, - providerTurnId: null, - providerThreadId: restartProviderThread.id, - providerInstanceId: input.modelSelection.instanceId, - }); - const interruptedAttemptId = targetRun.activeAttemptId; - if (interruptedAttemptId === null) { - return yield* new OrchestratorDispatchError({ - commandId: input.command.commandId, - commandType: input.command.type, - cause: `Active run ${targetRun.id} has no attempt to interrupt.`, - }); - } + nodeId: rootNodeId, + providerTurnId: providerTurn.id, + providerThreadId: providerThread.id, + providerInstanceId: targetRun.providerInstanceId, + }); yield* Ref.update(input.effects, (existing) => [ ...existing, { - id: `effect:${input.command.commandId}:provider-turn.restart:${providerTurn.id}`, + id: `effect:${input.command.commandId}:provider-turn.steer:${providerTurn.id}`, commandId: input.command.commandId, threadId: input.command.threadId, request: { - type: "provider-turn.restart", + type: "provider-turn.steer", providerSessionId, providerThreadId: providerThread.id, providerTurnId: providerTurn.id, - interruptedAttemptId, - runId: targetRun.id, - ...(restartSessionTransition === null - ? {} - : { sessionTransition: restartSessionTransition }), + messageId: input.messageId, }, } satisfies PendingOrchestrationEffectV2, ]); + return; + } + + const currentAttempt = input.projection.attempts.find( + (candidate) => candidate.id === targetRun.activeAttemptId, + ); + const currentRootNode = input.projection.nodes.find((candidate) => candidate.id === rootNodeId); + const attemptOrdinal = + Math.max( + 0, + ...input.projection.attempts + .filter((candidate) => candidate.runId === targetRun.id) + .map((candidate) => candidate.attemptOrdinal), + ) + 1; + const nextAttemptId = idAllocator.derive.runAttempt({ + runId: targetRun.id, + attemptOrdinal, + }); + const nextRootNodeId = idAllocator.derive.rootNodeAttempt({ + runId: targetRun.id, + attemptOrdinal, + }); + let restartProviderThread = providerThread; + let restartSessionTransition: + | { + readonly type: "replace"; + readonly replacementProviderSessionId: ProviderSessionId; + } + | { readonly type: "detach" } + | null = null; + let restartHandoff: OrchestrationV2ContextHandoff | null = null; + let restartTransfer: OrchestrationV2ContextTransfer | null = null; + const canResumeAcrossInstances = + providerInstanceChanged && + providerThread.nativeThreadRef !== null && + (yield* providerSwitchService + .plan({ + projection: { + ...input.projection, + thread: { ...input.projection.thread, modelSelection: targetRun.modelSelection }, + }, + targetModelSelection: input.modelSelection, + }) + .pipe(mapDispatchError(input.command))).transition.type === "restart_and_resume"; + const requiresProviderThreadHandoff = + (providerInstanceChanged && !canResumeAcrossInstances) || + selectionTransition?.type === "create_with_handoff"; + const requiresProviderSessionRestart = + canResumeAcrossInstances || selectionTransition?.type === "restart_session"; + if (requiresProviderThreadHandoff) { + const targetAdapter = yield* providerAdapters.get(input.modelSelection.instanceId).pipe( + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: input.command.commandId, + providerInstanceId: input.modelSelection.instanceId, + cause, + }), + ), + ); + const targetCapabilities = yield* targetAdapter.getCapabilities().pipe( + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: input.command.commandId, + providerInstanceId: input.modelSelection.instanceId, + cause, + }), + ), + ); + yield* enforceCommandPolicy(input.command)( + commandPolicy.ensureContextHandoff({ + commandId: input.command.commandId, + threadId: input.command.threadId, + providerInstanceId: input.modelSelection.instanceId, + capabilities: targetCapabilities, + strategy: "full_thread_summary", + }), + ); + const existingTargetProviderThread = rootProviderThreadsForProvider( + input.projection, + input.modelSelection.instanceId, + ).find((candidate) => candidate.id !== providerThread.id); + const targetProviderSessionId = + existingTargetProviderThread?.providerSessionId ?? + (yield* mapDispatchError(input.command)( + providerSessionIdFor({ + adapter: targetAdapter, + providerInstanceId: input.modelSelection.instanceId, + threadId: input.command.threadId, + }), + )); + const targetProviderThreadBase: OrchestrationV2ProviderThread = + existingTargetProviderThread === undefined + ? { + id: idAllocator.derive.providerThread({ + driver: targetAdapter.driver, + nativeThreadId: `pending:${targetRun.id}:attempt:${attemptOrdinal}`, + }), + driver: targetAdapter.driver, + providerInstanceId: input.modelSelection.instanceId, + providerSessionId: targetProviderSessionId, + appThreadId: input.command.threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: targetRun.ordinal, + lastRunOrdinal: targetRun.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + } + : { + ...existingTargetProviderThread, + providerSessionId: targetProviderSessionId, + lastRunOrdinal: targetRun.ordinal, + updatedAt: now, + }; + const transferId = yield* mapDispatchError(input.command)( + idAllocator.allocate.contextTransfer({ + sourceThreadId: input.command.threadId, + targetThreadId: input.command.threadId, + type: "provider_handoff", + }), + ); + restartHandoff = yield* contextHandoffService + .prepareProviderHandoff({ + threadId: input.command.threadId, + targetRunId: targetRun.id, + transferId, + fromProviderThreadIds: [providerThread.id], + toProviderThreadId: targetProviderThreadBase.id, + fromProviderInstanceId: targetRun.providerInstanceId, + toProviderInstanceId: input.modelSelection.instanceId, + coveredRunOrdinals: { from: 1, to: targetRun.ordinal }, + strategy: "full_thread_summary", + items: yield* readHandoffItems(input.command.threadId), + runs: input.projection.runs, + createdAt: now, + }) + .pipe(mapDispatchError(input.command)); + restartProviderThread = { + ...targetProviderThreadBase, + handoffIds: Array.from( + new Set([...targetProviderThreadBase.handoffIds, restartHandoff.id]), + ), + }; + restartTransfer = { + id: transferId, + type: "provider_handoff", + sourceThreadId: input.command.threadId, + targetThreadId: input.command.threadId, + sourcePoint: contextSourcePointForRun(input.projection, targetRun), + basePoint: null, + sourceProviderInstanceId: targetRun.providerInstanceId, + targetProviderInstanceId: input.modelSelection.instanceId, + targetRunId: targetRun.id, + status: "consumed", + resolution: { + strategy: "portable_context", + contextHandoffId: restartHandoff.id, + }, + createdBy: input.createdBy, + error: null, + createdAt: now, + updatedAt: now, + consumedAt: now, + }; + restartSessionTransition = { type: "detach" }; + } else if (requiresProviderSessionRestart) { + const nextProviderSessionId = yield* mapDispatchError(input.command)( + idAllocator.allocate.providerSession({ + providerInstanceId: input.modelSelection.instanceId, + threadId: input.command.threadId, + }), + ); + restartProviderThread = { + ...providerThread, + providerInstanceId: input.modelSelection.instanceId, + providerSessionId: nextProviderSessionId, + status: "not_loaded", + updatedAt: now, + }; + restartSessionTransition = { + type: "replace", + replacementProviderSessionId: nextProviderSessionId, + }; + } + const resolvedRuntimePolicy = yield* runtimePolicy + .resolve({ thread: input.projection.thread, modelSelection: input.modelSelection }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause, + }), + ), + ); + const checkpointScope = yield* checkpointService + .prepareRootRunScope({ + threadId: input.command.threadId, + runId: targetRun.id, + rootNodeId: nextRootNodeId, + providerThreadId: restartProviderThread.id, + cwd: + resolvedRuntimePolicy.cwd ?? + input.projection.thread.worktreePath ?? + session.providerSession.cwd, + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause, + }), + ), + ); + const ensuredCheckpointScope = yield* checkpointService.ensureScope(checkpointScope).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause, + }), + ), + ); + const restartedRun: OrchestrationV2Run = { + ...targetRun, + providerInstanceId: input.modelSelection.instanceId, + modelSelection: input.modelSelection, + providerThreadId: restartProviderThread.id, + rootNodeId: nextRootNodeId, + activeAttemptId: nextAttemptId, + userMessageId: input.messageId, + status: "starting", + contextHandoffId: restartHandoff?.id ?? targetRun.contextHandoffId, + }; + const nextAttempt: OrchestrationV2RunAttempt = { + id: nextAttemptId, + runId: targetRun.id, + attemptOrdinal, + rootNodeId: nextRootNodeId, + providerInstanceId: input.modelSelection.instanceId, + providerThreadId: restartProviderThread.id, + providerTurnId: null, + reason: "steering_restart", + status: "pending", + startedAt: null, + completedAt: null, + }; + const nextRootNode: OrchestrationV2ExecutionNode = { + id: nextRootNodeId, + threadId: input.command.threadId, + runId: targetRun.id, + parentNodeId: null, + rootNodeId: nextRootNodeId, + kind: "root_turn", + status: "pending", + countsForRun: true, + providerThreadId: restartProviderThread.id, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: ensuredCheckpointScope.id, + startedAt: null, + completedAt: null, + }; + if (currentAttempt !== undefined) { + yield* emitEvent({ + type: "run-attempt.updated", + threadId: input.command.threadId, + runId: targetRun.id, + nodeId: rootNodeId, + providerInstanceId: targetRun.providerInstanceId, + occurredAt: now, + payload: { ...currentAttempt, status: "superseded", completedAt: now }, + }); + } + if (currentRootNode !== undefined) { + yield* emitEvent({ + type: "node.updated", + threadId: input.command.threadId, + runId: targetRun.id, + nodeId: rootNodeId, + providerInstanceId: targetRun.providerInstanceId, + occurredAt: now, + payload: { ...currentRootNode, status: "interrupted", completedAt: now }, + }); + } + if (selectionChanged) { + yield* emitEvent({ + type: providerInstanceChanged + ? "thread.provider-switched" + : "thread.model-selection-updated", + threadId: input.command.threadId, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: { + ...input.projection.thread, + providerInstanceId: input.modelSelection.instanceId, + modelSelection: input.modelSelection, + updatedAt: now, + }, + }); + } + if (requiresProviderThreadHandoff || requiresProviderSessionRestart) { + yield* emitEvent({ + type: "provider-thread.updated", + threadId: input.command.threadId, + driver: restartProviderThread.driver, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: restartProviderThread, + }); + } + if (restartHandoff !== null) { + yield* emitEvent({ + type: "context-handoff.updated", + threadId: input.command.threadId, + runId: targetRun.id, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: restartHandoff, + }); + } + if (restartTransfer !== null) { + yield* emitEvent({ + type: "context-transfer.created", + threadId: input.command.threadId, + runId: targetRun.id, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: restartTransfer, + }); + } + yield* emitEvent({ + type: "run.updated", + threadId: input.command.threadId, + runId: targetRun.id, + nodeId: nextRootNodeId, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: restartedRun, + }); + yield* emitEvent({ + type: "run-attempt.created", + threadId: input.command.threadId, + runId: targetRun.id, + nodeId: nextRootNodeId, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: nextAttempt, + }); + yield* emitEvent({ + type: "node.updated", + threadId: input.command.threadId, + runId: targetRun.id, + nodeId: nextRootNodeId, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: nextRootNode, + }); + yield* emitEvent({ + type: "checkpoint-scope.created", + threadId: input.command.threadId, + runId: targetRun.id, + nodeId: nextRootNodeId, + providerInstanceId: input.modelSelection.instanceId, + occurredAt: now, + payload: ensuredCheckpointScope, + }); + yield* appendSteeringMessage({ + runId: targetRun.id, + nodeId: nextRootNodeId, + providerTurnId: null, + providerThreadId: restartProviderThread.id, + providerInstanceId: input.modelSelection.instanceId, }); + const interruptedAttemptId = targetRun.activeAttemptId; + if (interruptedAttemptId === null) { + return yield* new OrchestratorDispatchError({ + commandId: input.command.commandId, + commandType: input.command.type, + cause: `Active run ${targetRun.id} has no attempt to interrupt.`, + }); + } + yield* Ref.update(input.effects, (existing) => [ + ...existing, + { + id: `effect:${input.command.commandId}:provider-turn.restart:${providerTurn.id}`, + commandId: input.command.commandId, + threadId: input.command.threadId, + request: { + type: "provider-turn.restart", + providerSessionId, + providerThreadId: providerThread.id, + providerTurnId: providerTurn.id, + interruptedAttemptId, + runId: targetRun.id, + ...(restartSessionTransition === null + ? {} + : { sessionTransition: restartSessionTransition }), + }, + } satisfies PendingOrchestrationEffectV2, + ]); + }); - const dispatchMessage = ( + const dispatchMessage = Effect.fn("Orchestrator.dispatchMessage")(function* ( command: Extract, events: Ref.Ref>, effects: Ref.Ref>, - ) => - Effect.gen(function* () { - let projection = yield* getProjectionWithPendingEvents(command.threadId, events); - if (command.manualContinuationOfRunId !== undefined) { - const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId); - const limited = latestRootProviderFailure(source ?? null, projection.turnItems); - if ( - command.dispatchMode.type !== "start_immediately" || - source === undefined || - (source.status !== "interrupted" && - !(source.status === "failed" && limited?.class === "usage_limit")) || - latestExecutedRun(projection.runs)?.id !== source.id || - projection.thread.archivedAt !== null || - projection.thread.deletedAt !== null || - projection.runtimeRequests.some((request) => request.status === "pending") - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "This thread can no longer be resumed from that run.", - }); - } - } - if (command.usageLimitContinuationOfRunId !== undefined) { - const run = usageLimitBlockedRun(projection.runs, projection.turnItems, null); - const failure = latestRootProviderFailure(run, projection.turnItems); - const recovery = projection.thread.limitRecovery; - const now = yield* DateTime.now; - if ( - run?.id !== command.usageLimitContinuationOfRunId || - failure?.class !== "usage_limit" || - threadShellFromProjection(projection).lastErrorClass !== "usage_limit" || - !recovery?.autoResume || - recovery.requestId !== command.usageLimitRecoveryRequestId || - recovery.runId !== run.id || - recovery.resetAt !== failure.resetAt || - Date.parse(recovery.resetAt) > DateTime.toEpochMillis(now) || - projection.thread.archivedAt !== null || - projection.thread.deletedAt !== null || - projection.thread.settledOverride === "settled" || - projection.thread.providerInstanceId !== run.providerInstanceId || - projection.runtimeRequests.some((request) => request.status === "pending") || - (projection.thread.snoozedUntil != null && - DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now)) - ) { - yield* emit( - events, - command, - )({ - type: "thread.metadata-updated", - threadId: command.threadId, - occurredAt: now, - payload: { - ...projection.thread, - ...(recovery?.autoResume && - recovery.requestId === command.usageLimitRecoveryRequestId && - recovery.runId === command.usageLimitContinuationOfRunId && - projection.thread.snoozedUntil != null && - DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now) - ? { limitRecovery: { ...recovery, requestId: command.commandId } } - : {}), - }, - }); - return; - } + ) { + let projection = yield* getProjectionWithPendingEvents(command.threadId, events); + if (command.manualContinuationOfRunId !== undefined) { + const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId); + const limited = latestRootProviderFailure(source ?? null, projection.turnItems); + if ( + command.dispatchMode.type !== "start_immediately" || + source === undefined || + (source.status !== "interrupted" && + !(source.status === "failed" && limited?.class === "usage_limit")) || + latestExecutedRun(projection.runs)?.id !== source.id || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null || + projection.runtimeRequests.some((request) => request.status === "pending") + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "This thread can no longer be resumed from that run.", + }); } - - if (command.restartContinuationOfRunId !== undefined) { - const source = projection.runs.find((run) => run.id === command.restartContinuationOfRunId); - if ( - !source || - !isRestartContinuationSource(source, projection.providerTurns) || - projection.thread.archivedAt !== null || - projection.thread.deletedAt !== null || - projection.thread.providerInstanceId !== source.providerInstanceId || - projection.runs.some( - (run) => - run.id !== source.id && - run.status !== "queued" && - !cancelledBeforeStart(run) && - runRanAfter(run, source), - ) || - (yield* stopReachedRun(command, command.threadId, source.id)) - ) { - // Preserve the current row so stale automatic deliveries receive an - // accepted receipt without changing work or repeatedly retrying. - yield* emit( - events, - command, - )({ - type: "thread.metadata-updated", - threadId: command.threadId, - occurredAt: yield* DateTime.now, - payload: projection.thread, - }); - return; - } - } - - if (projection.thread.lastVisitedAt === null && !isNativeMaintenanceCommand(command)) { - const now = yield* DateTime.now; - // Null keeps imported history read. The first fresh dispatch establishes - // a read watermark so its completion can be unread on every client. - // Offset it so a completion in the same millisecond is still unread. - const event = yield* emit( + } + if (command.usageLimitContinuationOfRunId !== undefined) { + const run = usageLimitBlockedRun(projection.runs, projection.turnItems, null); + const failure = latestRootProviderFailure(run, projection.turnItems); + const recovery = projection.thread.limitRecovery; + const now = yield* DateTime.now; + if ( + run?.id !== command.usageLimitContinuationOfRunId || + failure?.class !== "usage_limit" || + threadShellFromProjection(projection).lastErrorClass !== "usage_limit" || + !recovery?.autoResume || + recovery.requestId !== command.usageLimitRecoveryRequestId || + recovery.runId !== run.id || + recovery.resetAt !== failure.resetAt || + Date.parse(recovery.resetAt) > DateTime.toEpochMillis(now) || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null || + projection.thread.settledOverride === "settled" || + projection.thread.providerInstanceId !== run.providerInstanceId || + projection.runtimeRequests.some((request) => request.status === "pending") || + (projection.thread.snoozedUntil != null && + DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now)) + ) { + yield* emit( events, command, )({ type: "thread.metadata-updated", threadId: command.threadId, - providerInstanceId: projection.thread.providerInstanceId, occurredAt: now, payload: { ...projection.thread, - lastVisitedAt: DateTime.subtract(now, { milliseconds: 1 }), + ...(recovery?.autoResume && + recovery.requestId === command.usageLimitRecoveryRequestId && + recovery.runId === command.usageLimitContinuationOfRunId && + projection.thread.snoozedUntil != null && + DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now) + ? { limitRecovery: { ...recovery, requestId: command.commandId } } + : {}), }, }); - projection = applyToProjection(projection, event); + return; } + } - const continuedTaskId = projection.runs.find( - (run) => run.id === command.restartContinuationOfRunId, - )?.delegatedTaskId; - - if (projection.thread.settledOverride !== null) { - const now = yield* DateTime.now; - const thread: OrchestrationV2AppThread = { - ...projection.thread, - settledOverride: null, - settledAt: null, - unsettledAt: - projection.thread.settledOverride === "active" - ? (projection.thread.unsettledAt ?? null) - : now, - updatedAt: now, - }; - yield* emit( - events, - command, - )({ - type: "thread.unsettled", - threadId: command.threadId, - providerInstanceId: thread.providerInstanceId, - occurredAt: now, - payload: thread, - }); - projection = yield* getProjectionWithPendingEvents(command.threadId, events); - } - if (projection.thread.snoozedUntil != null) { - const now = yield* DateTime.now; - const thread: OrchestrationV2AppThread = { - ...projection.thread, - snoozedUntil: null, - snoozedAt: null, - updatedAt: now, - }; - yield* emit( - events, - command, - )({ - type: "thread.unsnoozed", - threadId: command.threadId, - providerInstanceId: thread.providerInstanceId, - occurredAt: now, - payload: thread, - }); - projection = yield* getProjectionWithPendingEvents(command.threadId, events); - } - const userMessages = projection.messages.filter((message) => message.role === "user"); - const onlyMaintenanceHistory = - userMessages.length > 0 && userMessages.every(isNativeMaintenanceCommand); + if (command.restartContinuationOfRunId !== undefined) { + const source = projection.runs.find((run) => run.id === command.restartContinuationOfRunId); if ( - !isNativeMaintenanceCommand(command) && - ((command.titleSeed !== undefined && - (yield* projectionStore - .getMessageCount(command.threadId) - .pipe(mapDispatchError(command))) === 0) || - (command.createdBy === "user" && onlyMaintenanceHistory)) + !source || + !isRestartContinuationSource(source, projection.providerTurns) || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null || + projection.thread.providerInstanceId !== source.providerInstanceId || + projection.runs.some( + (run) => + run.id !== source.id && + run.status !== "queued" && + !cancelledBeforeStart(run) && + runRanAfter(run, source), + ) || + (yield* stopReachedRun(command, command.threadId, source.id)) ) { - const now = yield* DateTime.now; - const thread: OrchestrationV2AppThread = { - ...projection.thread, - title: command.titleSeed ?? projection.thread.title, - titleRegeneration: { requestId: command.commandId, startedAt: now }, - updatedAt: now, - }; + // Preserve the current row so stale automatic deliveries receive an + // accepted receipt without changing work or repeatedly retrying. yield* emit( events, command, )({ type: "thread.metadata-updated", threadId: command.threadId, - providerInstanceId: thread.providerInstanceId, - occurredAt: now, - payload: thread, + occurredAt: yield* DateTime.now, + payload: projection.thread, }); - yield* Ref.update(effects, (existing) => [ - ...existing, - pendingThreadTitleGenerationEffect(command.commandId, command.threadId, { - type: "initial", - messageId: command.messageId, - }), - ]); - projection = yield* getProjectionWithPendingEvents(command.threadId, events); + return; } - const modelSelection = command.modelSelection ?? projection.thread.modelSelection; - let dispatchMode = resolveMessageDispatchIntent( - projection, - command.dispatchMode, - command.deliveryIntent, + } + + if (projection.thread.lastVisitedAt === null && !isNativeMaintenanceCommand(command)) { + const now = yield* DateTime.now; + // Null keeps imported history read. The first fresh dispatch establishes + // a read watermark so its completion can be unread on every client. + // Offset it so a completion in the same millisecond is still unread. + const event = yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: projection.thread.providerInstanceId, + occurredAt: now, + payload: { + ...projection.thread, + lastVisitedAt: DateTime.subtract(now, { milliseconds: 1 }), + }, + }); + projection = applyToProjection(projection, event); + } + + const continuedTaskId = projection.runs.find( + (run) => run.id === command.restartContinuationOfRunId, + )?.delegatedTaskId; + + if (projection.thread.settledOverride !== null) { + const now = yield* DateTime.now; + const thread: OrchestrationV2AppThread = { + ...projection.thread, + settledOverride: null, + settledAt: null, + unsettledAt: + projection.thread.settledOverride === "active" + ? (projection.thread.unsettledAt ?? null) + : now, + updatedAt: now, + }; + yield* emit( + events, + command, + )({ + type: "thread.unsettled", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: thread, + }); + projection = yield* getProjectionWithPendingEvents(command.threadId, events); + } + if (projection.thread.snoozedUntil != null) { + const now = yield* DateTime.now; + const thread: OrchestrationV2AppThread = { + ...projection.thread, + snoozedUntil: null, + snoozedAt: null, + updatedAt: now, + }; + yield* emit( + events, + command, + )({ + type: "thread.unsnoozed", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: thread, + }); + projection = yield* getProjectionWithPendingEvents(command.threadId, events); + } + const userMessages = projection.messages.filter((message) => message.role === "user"); + const onlyMaintenanceHistory = + userMessages.length > 0 && userMessages.every(isNativeMaintenanceCommand); + if ( + !isNativeMaintenanceCommand(command) && + ((command.titleSeed !== undefined && + (yield* projectionStore + .getMessageCount(command.threadId) + .pipe(mapDispatchError(command))) === 0) || + (command.createdBy === "user" && onlyMaintenanceHistory)) + ) { + const now = yield* DateTime.now; + const thread: OrchestrationV2AppThread = { + ...projection.thread, + title: command.titleSeed ?? projection.thread.title, + titleRegeneration: { requestId: command.commandId, startedAt: now }, + updatedAt: now, + }; + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: thread, + }); + yield* Ref.update(effects, (existing) => [ + ...existing, + pendingThreadTitleGenerationEffect(command.commandId, command.threadId, { + type: "initial", + messageId: command.messageId, + }), + ]); + projection = yield* getProjectionWithPendingEvents(command.threadId, events); + } + const modelSelection = command.modelSelection ?? projection.thread.modelSelection; + let dispatchMode = resolveMessageDispatchIntent( + projection, + command.dispatchMode, + command.deliveryIntent, + ); + if (dispatchMode.type === "steer_active") { + const targetRunId = dispatchMode.targetRunId; + const target = projection.runs.find((run) => run.id === targetRunId); + const turn = latestProviderTurnForAttempt( + projection.providerTurns.filter((candidate) => candidate.nodeId === target?.rootNodeId), + target?.activeAttemptId, ); - if (dispatchMode.type === "steer_active") { - const targetRunId = dispatchMode.targetRunId; - const target = projection.runs.find((run) => run.id === targetRunId); - const turn = latestProviderTurnForAttempt( - projection.providerTurns.filter((candidate) => candidate.nodeId === target?.rootNodeId), - target?.activeAttemptId, - ); - // The client may still show a running turn while its completion is being - // projected. Preserve the submission as a new turn when steering is too late. - if ( - target !== undefined && - (target.status === "completed" || - ((target.status === "running" || target.status === "waiting") && - turn !== undefined && - turn.status === "completed")) - ) { - dispatchMode = { type: "start_immediately" }; - } - } - // Stop leaves the run reading as running until its provider stops. A plain - // send in that window queues behind the stopped queue instead of steering - // text the agent never receives, and like any user send it releases the - // hold; an explicit steer or restart still fails below. + // The client may still show a running turn while its completion is being + // projected. Preserve the submission as a new turn when steering is too late. if ( - command.deliveryIntent === "auto" && - (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") && - (yield* stopReachedRun(command, command.threadId, dispatchMode.targetRunId)) + target !== undefined && + (target.status === "completed" || + ((target.status === "running" || target.status === "waiting") && + turn !== undefined && + turn.status === "completed")) ) { - dispatchMode = { type: "queue_after_active" }; + dispatchMode = { type: "start_immediately" }; } + } + // Stop leaves the run reading as running until its provider stops. A plain + // send in that window queues behind the stopped queue instead of steering + // text the agent never receives, and like any user send it releases the + // hold; an explicit steer or restart still fails below. + if ( + command.deliveryIntent === "auto" && + (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") && + (yield* stopReachedRun(command, command.threadId, dispatchMode.targetRunId)) + ) { + dispatchMode = { type: "queue_after_active" }; + } + if ( + command.notification !== undefined && + (command.createdBy !== "agent" || + (command.creationSource !== "server" && command.creationSource !== "provider") || + (dispatchMode.type !== "queue_after_active" && + !( + command.restartContinuationOfRunId !== undefined && + dispatchMode.type === "start_immediately" + ))) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Notifications must be server- or provider-created queued messages.", + }); + } + let delegatedCompletion: OrchestrationV2ConversationMessage["delegatedCompletion"] | undefined; + if (command.delegatedCompletion !== undefined) { + const requestedCompletion = command.delegatedCompletion; if ( - command.notification !== undefined && - (command.createdBy !== "agent" || - (command.creationSource !== "server" && command.creationSource !== "provider") || - (dispatchMode.type !== "queue_after_active" && - !( - command.restartContinuationOfRunId !== undefined && - dispatchMode.type === "start_immediately" - ))) + command.createdBy !== "agent" || + command.creationSource !== "server" || + dispatchMode.type !== "queue_after_active" ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: "Notifications must be server- or provider-created queued messages.", + cause: "Delegated completion delivery must be a server-created queued message.", }); } - let delegatedCompletion: - | OrchestrationV2ConversationMessage["delegatedCompletion"] - | undefined; - if (command.delegatedCompletion !== undefined) { - const requestedCompletion = command.delegatedCompletion; - if ( - command.createdBy !== "agent" || - command.creationSource !== "server" || - dispatchMode.type !== "queue_after_active" - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Delegated completion delivery must be a server-created queued message.", - }); - } - const parentRun = projection.runs.find( - (candidate) => candidate.id === requestedCompletion.parentRunId, - ); - const delivery = parentRun?.delegatedCompletion?.delivery; - if ( - parentRun?.delegatedCompletion?.disposition !== "open" || - delivery === null || - delivery === undefined || - delivery.generation !== requestedCompletion.generation || - delivery.messageId !== command.messageId || - (projection.messages.some((candidate) => candidate.id === command.messageId) && - !isUndeliveredMailboxSteer(projection, command.messageId)) - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Delegated completion delivery is no longer dispatchable.", - }); - } - delegatedCompletion = { - parentRunId: requestedCompletion.parentRunId, - generation: delivery.generation, - taskIds: delivery.taskIds, - }; - } - // Route durable mailbox deliveries under the thread lock, using the live - // session's capabilities. Never interrupt/restart a turn for a notification. + const parentRun = projection.runs.find( + (candidate) => candidate.id === requestedCompletion.parentRunId, + ); + const delivery = parentRun?.delegatedCompletion?.delivery; if ( - delegatedCompletion !== undefined && - delegatedCompletion.taskIds.every( - (id) => projection.subagents.find((task) => task.id === id)?.completionWake === "always", - ) + parentRun?.delegatedCompletion?.disposition !== "open" || + delivery === null || + delivery === undefined || + delivery.generation !== requestedCompletion.generation || + delivery.messageId !== command.messageId || + (projection.messages.some((candidate) => candidate.id === command.messageId) && + !isUndeliveredMailboxSteer(projection, command.messageId)) ) { - const active = projection.runs.find((run) => run.status === "running"); - const providerThread = projection.providerThreads.find( - (row) => row.id === active?.providerThreadId, - ); - const activeTurn = projection.providerTurns.find( - (turn) => turn.runAttemptId === active?.activeAttemptId && turn.status === "running", - ); - const activeMessage = projection.messages.find( - (message) => message.id === active?.userMessageId, - ); - if ( - active !== undefined && - activeTurn !== undefined && - providerThread?.providerSessionId != null && - (activeMessage === undefined || !isNativeMaintenanceCommand(activeMessage)) - ) { - const session = yield* providerSessions - .get(providerThread.providerSessionId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if ( - Option.isSome(session) && - session.value.providerSession.capabilities.turns.supportsActiveSteering && - session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true - ) { - dispatchMode = { type: "steer_active", targetRunId: active.id }; - } - } - } - const dispatchText = - delegatedCompletion === undefined - ? command.text - : delegatedCompletionWakeDetail(delegatedCompletion.taskIds); - const sourcePlanProjection = - command.sourcePlanRef === undefined - ? null - : yield* getProjectionWithPendingEvents(command.sourcePlanRef.threadId, events); - const sourcePlanArtifact = - command.sourcePlanRef === undefined - ? undefined - : yield* projectionStore - .getPlan(command.sourcePlanRef.threadId, command.sourcePlanRef.planId) - .pipe(mapDispatchError(command)); - const sourcePlan = sourcePlanArtifact?.kind === "proposed_plan" ? sourcePlanArtifact : null; - if (command.sourcePlanRef !== undefined && sourcePlan === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Proposed plan ${command.sourcePlanRef.planId} does not exist on thread ${command.sourcePlanRef.threadId}.`, + cause: "Delegated completion delivery is no longer dispatchable.", }); } + delegatedCompletion = { + parentRunId: requestedCompletion.parentRunId, + generation: delivery.generation, + taskIds: delivery.taskIds, + }; + } + // Route durable mailbox deliveries under the thread lock, using the live + // session's capabilities. Never interrupt/restart a turn for a notification. + if ( + delegatedCompletion !== undefined && + delegatedCompletion.taskIds.every( + (id) => projection.subagents.find((task) => task.id === id)?.completionWake === "always", + ) + ) { + const active = projection.runs.find((run) => run.status === "running"); + const providerThread = projection.providerThreads.find( + (row) => row.id === active?.providerThreadId, + ); + const activeTurn = projection.providerTurns.find( + (turn) => turn.runAttemptId === active?.activeAttemptId && turn.status === "running", + ); + const activeMessage = projection.messages.find( + (message) => message.id === active?.userMessageId, + ); if ( - sourcePlanProjection !== null && - sourcePlanProjection.thread.projectId !== projection.thread.projectId + active !== undefined && + activeTurn !== undefined && + providerThread?.providerSessionId != null && + (activeMessage === undefined || !isNativeMaintenanceCommand(activeMessage)) ) { + const session = yield* providerSessions + .get(providerThread.providerSessionId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if ( + Option.isSome(session) && + session.value.providerSession.capabilities.turns.supportsActiveSteering && + session.value.providerSession.capabilities.turns.activeSteeringInterruptsTools !== true + ) { + dispatchMode = { type: "steer_active", targetRunId: active.id }; + } + } + } + const dispatchText = + delegatedCompletion === undefined + ? command.text + : delegatedCompletionWakeDetail(delegatedCompletion.taskIds); + const sourcePlanProjection = + command.sourcePlanRef === undefined + ? null + : yield* getProjectionWithPendingEvents(command.sourcePlanRef.threadId, events); + const sourcePlanArtifact = + command.sourcePlanRef === undefined + ? undefined + : yield* projectionStore + .getPlan(command.sourcePlanRef.threadId, command.sourcePlanRef.planId) + .pipe(mapDispatchError(command)); + const sourcePlan = sourcePlanArtifact?.kind === "proposed_plan" ? sourcePlanArtifact : null; + if (command.sourcePlanRef !== undefined && sourcePlan === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Proposed plan ${command.sourcePlanRef.planId} does not exist on thread ${command.sourcePlanRef.threadId}.`, + }); + } + if ( + sourcePlanProjection !== null && + sourcePlanProjection.thread.projectId !== projection.thread.projectId + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Proposed plan ${command.sourcePlanRef?.planId} belongs to a different project.`, + }); + } + if (sourcePlan !== null && sourcePlan.status !== "active") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Proposed plan ${sourcePlan.id} is not active.`, + }); + } + const completeSourcePlan = (occurredAt: DateTime.Utc) => + sourcePlan === null + ? Effect.void + : emit( + events, + command, + )({ + type: "plan.updated", + threadId: sourcePlan.threadId, + ...(sourcePlan.runId === null ? {} : { runId: sourcePlan.runId }), + nodeId: sourcePlan.nodeId, + occurredAt, + payload: { ...sourcePlan, status: "completed" }, + }); + + if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { + yield* dispatchSteerIntoRun({ + command, + events, + effects, + projection, + modelSelection: + delegatedCompletion === undefined + ? modelSelection + : (projection.runs.find((run) => run.id === dispatchMode.targetRunId)?.modelSelection ?? + modelSelection), + delegatedCompletion, + targetRunId: dispatchMode.targetRunId, + messageId: command.messageId, + text: dispatchText, + ...(command.context ? { context: command.context } : {}), + attachments: command.attachments, + createdBy: command.createdBy, + creationSource: command.creationSource, + ...(command.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + forceRestart: dispatchMode.type === "restart_active", + }); + return; + } + + const activeProviderThread = projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + const activeRun = projection.runs.find(isBlockingRun); + // A message queued during worktree setup can arrive after that setup failed + // without a worktree. It waits, held, like the messages queued before the + // failure, instead of starting in the project checkout. + const latestRun = latestExecutedRun(projection.runs); + const worktreeMissingRun = + activeRun === undefined && + dispatchMode.type === "queue_after_active" && + (latestRun?.status === "failed" || latestRun?.status === "interrupted") && + latestRun.workspacePreparation?.type === "worktree" && + projection.thread.worktreePath === null + ? latestRun + : undefined; + const queueBehindRun = activeRun ?? worktreeMissingRun; + const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection); + const startsOrQueuesRun = + dispatchMode.type === "defer_start" || + 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. + const worktreeSetupUnresolved = + projection.thread.worktreePath === null && + projection.runs.some( + (run) => + (run.status === "failed" || run.status === "interrupted") && + run.workspacePreparation?.type === "worktree", + ); + const sentByUserFromClient = + command.createdBy === "user" && + (command.creationSource === "web" || command.creationSource === "mobile") && + command.scheduledTaskId === undefined; + const heldRunsToRelease = + sentByUserFromClient && startsOrQueuesRun && !worktreeSetupUnresolved + ? projection.runs.filter((run) => run.status === "queued" && run.queueHeld === true) + : []; + const releasedAt = yield* DateTime.now; + for (const held of heldRunsToRelease) { + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: command.threadId, + runId: held.id, + providerInstanceId: held.providerInstanceId, + occurredAt: releasedAt, + payload: { ...held, queueHeld: false }, + }); + } + if (shouldQueue) { + if (pendingMergeBackTransfers.length > 0) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Proposed plan ${command.sourcePlanRef?.planId} belongs to a different project.`, + cause: `Thread ${command.threadId} has a pending merge-back transfer; queued merge-back consumption is not implemented yet.`, }); } - if (sourcePlan !== null && sourcePlan.status !== "active") { + const queueProviderThread = + activeProviderThread ?? + projection.providerThreads.find( + (candidate) => candidate.id === queueBehindRun.providerThreadId, + ); + if (queueProviderThread === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Proposed plan ${sourcePlan.id} is not active.`, + cause: `Active run ${queueBehindRun.id} has no provider thread for queued dispatch.`, }); } - const completeSourcePlan = (occurredAt: DateTime.Utc) => - sourcePlan === null - ? Effect.void - : emit( - events, - command, - )({ - type: "plan.updated", - threadId: sourcePlan.threadId, - ...(sourcePlan.runId === null ? {} : { runId: sourcePlan.runId }), - nodeId: sourcePlan.nodeId, - occurredAt, - payload: { ...sourcePlan, status: "completed" }, - }); - - if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { - yield* dispatchSteerIntoRun({ - command, - events, - effects, - projection, - modelSelection: - delegatedCompletion === undefined - ? modelSelection - : (projection.runs.find((run) => run.id === dispatchMode.targetRunId) - ?.modelSelection ?? modelSelection), - delegatedCompletion, - targetRunId: dispatchMode.targetRunId, - messageId: command.messageId, - text: dispatchText, - ...(command.context ? { context: command.context } : {}), - attachments: command.attachments, - createdBy: command.createdBy, - creationSource: command.creationSource, - ...(command.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: command.scheduledTaskId }), - ...(command.senderThreadId === undefined - ? {} - : { senderThreadId: command.senderThreadId }), - forceRestart: dispatchMode.type === "restart_active", - }); - return; - } - - const activeProviderThread = projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ); - const activeRun = projection.runs.find(isBlockingRun); - // A message queued during worktree setup can arrive after that setup failed - // without a worktree. It waits, held, like the messages queued before the - // failure, instead of starting in the project checkout. - const latestRun = latestExecutedRun(projection.runs); - const worktreeMissingRun = - activeRun === undefined && - dispatchMode.type === "queue_after_active" && - (latestRun?.status === "failed" || latestRun?.status === "interrupted") && - latestRun.workspacePreparation?.type === "worktree" && - projection.thread.worktreePath === null - ? latestRun - : undefined; - const queueBehindRun = activeRun ?? worktreeMissingRun; - const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection); - const startsOrQueuesRun = - dispatchMode.type === "defer_start" || - 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. - const worktreeSetupUnresolved = - projection.thread.worktreePath === null && - projection.runs.some( - (run) => - (run.status === "failed" || run.status === "interrupted") && - run.workspacePreparation?.type === "worktree", - ); - const sentByUserFromClient = - command.createdBy === "user" && - (command.creationSource === "web" || command.creationSource === "mobile") && - command.scheduledTaskId === undefined; - const heldRunsToRelease = - sentByUserFromClient && startsOrQueuesRun && !worktreeSetupUnresolved - ? projection.runs.filter((run) => run.status === "queued" && run.queueHeld === true) - : []; - const releasedAt = yield* DateTime.now; - for (const held of heldRunsToRelease) { - yield* emit( - events, - command, - )({ - type: "run.updated", - threadId: command.threadId, - runId: held.id, - providerInstanceId: held.providerInstanceId, - occurredAt: releasedAt, - payload: { ...held, queueHeld: false }, - }); - } - if (shouldQueue) { - if (pendingMergeBackTransfers.length > 0) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Thread ${command.threadId} has a pending merge-back transfer; queued merge-back consumption is not implemented yet.`, - }); - } - const queueProviderThread = - activeProviderThread ?? - projection.providerThreads.find( - (candidate) => candidate.id === queueBehindRun.providerThreadId, - ); - if (queueProviderThread === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Active run ${queueBehindRun.id} has no provider thread for queued dispatch.`, - }); - } - const now = yield* DateTime.now; - const ordinal = nextRunOrdinal(projection); - const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); - const targetProviderThread = - modelSelection.instanceId === queueProviderThread.providerInstanceId - ? queueProviderThread - : rootProviderThreadsForProvider(projection, modelSelection.instanceId)[0]; - const queuedAdapter = yield* providerAdapters - .get(modelSelection.instanceId) - .pipe(mapDispatchError(command)); - const selectedProviderSession = - targetProviderThread?.providerSessionId === null || - targetProviderThread?.providerSessionId === undefined - ? undefined - : projection.providerSessions.find( - (candidate) => candidate.id === targetProviderThread.providerSessionId, - ); - const queuedCapabilities = - selectedProviderSession?.capabilities ?? - (yield* queuedAdapter.getCapabilities().pipe(mapDispatchError(command))); - yield* enforceCommandPolicy(command)( - commandPolicy.ensureQueuedMessages({ - commandId: command.commandId, - threadId: command.threadId, - providerInstanceId: modelSelection.instanceId, - capabilities: queuedCapabilities, - }), - ); - const queuedProviderThread: OrchestrationV2ProviderThread = targetProviderThread ?? { - id: idAllocator.derive.providerThread({ - driver: queuedAdapter.driver, - nativeThreadId: `pending:${runId}`, - }), - driver: queuedAdapter.driver, - providerInstanceId: modelSelection.instanceId, - providerSessionId: null, - appThreadId: command.threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "not_loaded", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); - const rootNodeId = idAllocator.derive.rootNode({ runId }); - const checkpointScope = - queueBehindRun.status === "preparing" || worktreeMissingRun !== undefined - ? null - : yield* runtimePolicy.resolve({ thread: projection.thread, modelSelection }).pipe( - Effect.flatMap((resolvedRuntimePolicy) => - checkpointService.prepareRootRunScope({ - threadId: command.threadId, - runId, - rootNodeId, - providerThreadId: queuedProviderThread.id, - cwd: - resolvedRuntimePolicy.cwd ?? - selectedProviderSession?.cwd ?? - projection.thread.worktreePath ?? - process.cwd(), - createdAt: now, - }), - ), - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); - const run: OrchestrationV2Run = { - id: runId, - threadId: command.threadId, - ordinal, - providerInstanceId: modelSelection.instanceId, - modelSelection, - providerThreadId: queuedProviderThread.id, - userMessageId: command.messageId, - rootNodeId, - activeAttemptId: attemptId, - status: "queued", - ...(worktreeMissingRun !== undefined || - (heldRunsToRelease.length === 0 && - projection.runs.some( - (candidate) => candidate.status === "queued" && candidate.queueHeld === true, - )) - ? { queueHeld: true } - : {}), - queuePosition: - Math.max( - 0, - ...projection.runs - .filter((candidate) => candidate.status === "queued") - .map((candidate) => candidate.queuePosition ?? candidate.ordinal), - ) + 1, - requestedAt: now, - startedAt: null, - completedAt: null, - checkpointId: null, - contextHandoffId: null, - ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), - ...(command.restartContinuationOfRunId === undefined - ? {} - : { restartContinuationOfRunId: command.restartContinuationOfRunId }), - ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), - }; - const attempt: OrchestrationV2RunAttempt = { - id: attemptId, - runId, - attemptOrdinal: 1, - rootNodeId, - providerInstanceId: modelSelection.instanceId, - providerThreadId: queuedProviderThread.id, - providerTurnId: null, - reason: "initial", - status: "pending", - startedAt: null, - completedAt: null, - }; - const rootNode: OrchestrationV2ExecutionNode = { - id: rootNodeId, - threadId: command.threadId, - runId, - parentNodeId: null, - rootNodeId, - kind: "root_turn", - status: "pending", - countsForRun: true, - providerThreadId: queuedProviderThread.id, - providerTurnId: null, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: checkpointScope?.id ?? null, - startedAt: null, - completedAt: null, - }; - const message: OrchestrationV2ConversationMessage = { - createdBy: command.createdBy, - creationSource: command.creationSource, - ...(command.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: command.scheduledTaskId }), - ...(command.senderThreadId === undefined - ? {} - : { senderThreadId: command.senderThreadId }), - id: command.messageId, - threadId: command.threadId, - runId, - nodeId: rootNodeId, - role: "user", - text: dispatchText, - ...(command.context ? { context: command.context } : {}), - attachments: command.attachments, - streaming: false, - createdAt: now, - updatedAt: now, - ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), - ...(command.notification === undefined ? {} : { notification: command.notification }), - }; - const emitEvent = emit(events, command); - if (targetProviderThread === undefined) { - yield* emitEvent({ - type: "provider-thread.updated", - threadId: command.threadId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: queuedProviderThread, - }); - } - yield* emitEvent({ - type: "run.created", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: run, - }); - yield* completeSourcePlan(now); - yield* emitEvent({ - type: "run-attempt.created", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: attempt, - }); - yield* emitEvent({ - type: "node.updated", + const now = yield* DateTime.now; + const ordinal = nextRunOrdinal(projection); + const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); + const targetProviderThread = + modelSelection.instanceId === queueProviderThread.providerInstanceId + ? queueProviderThread + : rootProviderThreadsForProvider(projection, modelSelection.instanceId)[0]; + const queuedAdapter = yield* providerAdapters + .get(modelSelection.instanceId) + .pipe(mapDispatchError(command)); + const selectedProviderSession = + targetProviderThread?.providerSessionId === null || + targetProviderThread?.providerSessionId === undefined + ? undefined + : projection.providerSessions.find( + (candidate) => candidate.id === targetProviderThread.providerSessionId, + ); + const queuedCapabilities = + selectedProviderSession?.capabilities ?? + (yield* queuedAdapter.getCapabilities().pipe(mapDispatchError(command))); + yield* enforceCommandPolicy(command)( + commandPolicy.ensureQueuedMessages({ + commandId: command.commandId, threadId: command.threadId, - runId, - nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: rootNode, - }); - if (checkpointScope !== null) { - yield* emitEvent({ - type: "checkpoint-scope.created", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: yield* checkpointService.ensureScope(checkpointScope).pipe( + capabilities: queuedCapabilities, + }), + ); + const queuedProviderThread: OrchestrationV2ProviderThread = targetProviderThread ?? { + id: idAllocator.derive.providerThread({ + driver: queuedAdapter.driver, + nativeThreadId: `pending:${runId}`, + }), + driver: queuedAdapter.driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: null, + appThreadId: command.threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); + const rootNodeId = idAllocator.derive.rootNode({ runId }); + const checkpointScope = + queueBehindRun.status === "preparing" || worktreeMissingRun !== undefined + ? null + : yield* runtimePolicy.resolve({ thread: projection.thread, modelSelection }).pipe( + Effect.flatMap((resolvedRuntimePolicy) => + checkpointService.prepareRootRunScope({ + threadId: command.threadId, + runId, + rootNodeId, + providerThreadId: queuedProviderThread.id, + cwd: + resolvedRuntimePolicy.cwd ?? + selectedProviderSession?.cwd ?? + projection.thread.worktreePath ?? + process.cwd(), + createdAt: now, + }), + ), Effect.mapError( (cause) => new OrchestratorDispatchError({ @@ -5358,589 +5215,306 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause, }), ), - ), - }); - } + ); + const run: OrchestrationV2Run = { + id: runId, + threadId: command.threadId, + ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: queuedProviderThread.id, + userMessageId: command.messageId, + rootNodeId, + activeAttemptId: attemptId, + status: "queued", + ...(worktreeMissingRun !== undefined || + (heldRunsToRelease.length === 0 && + projection.runs.some( + (candidate) => candidate.status === "queued" && candidate.queueHeld === true, + )) + ? { queueHeld: true } + : {}), + queuePosition: + Math.max( + 0, + ...projection.runs + .filter((candidate) => candidate.status === "queued") + .map((candidate) => candidate.queuePosition ?? candidate.ordinal), + ) + 1, + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), + ...(command.restartContinuationOfRunId === undefined + ? {} + : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), + }; + const attempt: OrchestrationV2RunAttempt = { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId, + providerInstanceId: modelSelection.instanceId, + providerThreadId: queuedProviderThread.id, + providerTurnId: null, + reason: "initial", + status: "pending", + startedAt: null, + completedAt: null, + }; + const rootNode: OrchestrationV2ExecutionNode = { + id: rootNodeId, + threadId: command.threadId, + runId, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "pending", + countsForRun: true, + providerThreadId: queuedProviderThread.id, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: checkpointScope?.id ?? null, + startedAt: null, + completedAt: null, + }; + const message: OrchestrationV2ConversationMessage = { + createdBy: command.createdBy, + creationSource: command.creationSource, + ...(command.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + id: command.messageId, + threadId: command.threadId, + runId, + nodeId: rootNodeId, + role: "user", + text: dispatchText, + ...(command.context ? { context: command.context } : {}), + attachments: command.attachments, + streaming: false, + createdAt: now, + updatedAt: now, + ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), + ...(command.notification === undefined ? {} : { notification: command.notification }), + }; + const emitEvent = emit(events, command); + if (targetProviderThread === undefined) { yield* emitEvent({ - type: "message.updated", + type: "provider-thread.updated", threadId: command.threadId, - runId, - nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: message, + payload: queuedProviderThread, }); - const existingItem = projection.turnItems.find( - (item) => item.type === "user_message" && item.messageId === command.messageId, - ); - if (existingItem?.type === "user_message") { - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - ...existingItem, - runId, - nodeId: rootNodeId, - providerThreadId: queuedProviderThread.id, - providerTurnId: null, - inputIntent: "queued_turn", - updatedAt: now, - }, - }); - } - return; } - const pendingForkTransfer = pendingForkTransferForThread(projection); - const pendingMergeBackSourceThreadIds = new Set( - pendingMergeBackTransfers.map((transfer) => transfer.sourceThreadId), - ); - if (pendingMergeBackSourceThreadIds.size > 1) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Thread ${command.threadId} has pending merge-back transfers from multiple forks.`, + yield* emitEvent({ + type: "run.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: run, + }); + yield* completeSourcePlan(now); + yield* emitEvent({ + type: "run-attempt.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: attempt, + }); + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: rootNode, + }); + if (checkpointScope !== null) { + yield* emitEvent({ + type: "checkpoint-scope.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: yield* checkpointService.ensureScope(checkpointScope).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ), }); } - const pendingMergeBackTransfer = latestContextTransfer(pendingMergeBackTransfers); - const supersededMergeBackTransfers = pendingMergeBackTransfers.filter( - (transfer) => transfer.id !== pendingMergeBackTransfer?.id, + yield* emitEvent({ + type: "message.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: message, + }); + const existingItem = projection.turnItems.find( + (item) => item.type === "user_message" && item.messageId === command.messageId, ); - const now = yield* DateTime.now; - if ( - !modelSelectionsEqual(projection.thread.modelSelection, modelSelection) || - projection.thread.providerInstanceId !== modelSelection.instanceId - ) { - yield* emit( - events, - command, - )({ - type: - projection.thread.providerInstanceId === modelSelection.instanceId - ? "thread.model-selection-updated" - : "thread.provider-switched", + if (existingItem?.type === "user_message") { + yield* emitEvent({ + type: "turn-item.updated", threadId: command.threadId, + runId, + nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, payload: { - ...projection.thread, - providerInstanceId: modelSelection.instanceId, - modelSelection, + ...existingItem, + runId, + nodeId: rootNodeId, + providerThreadId: queuedProviderThread.id, + providerTurnId: null, + inputIntent: "queued_turn", updatedAt: now, }, }); } - const ordinal = nextRunOrdinal(projection); - const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); - const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); - const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); - const legacyImportItems = - projection.thread.historyOrigin === "v1_import" - ? yield* readHandoffItems(command.threadId, [null]) - : []; - const isProviderSwitch = - activeProviderThread !== undefined && - activeProviderThread.providerInstanceId !== modelSelection.instanceId; - // Account overlays share native history. Selection commands may already - // have updated the app thread, so classify against the native thread's owner. - const canResumeAcrossInstances = - isProviderSwitch && - activeProviderThread.nativeThreadRef !== null && - (yield* providerSwitchService - .plan({ - projection: { - ...projection, - thread: { - ...projection.thread, - modelSelection: { - ...projection.thread.modelSelection, - instanceId: activeProviderThread.providerInstanceId, - }, + return; + } + const pendingForkTransfer = pendingForkTransferForThread(projection); + const pendingMergeBackSourceThreadIds = new Set( + pendingMergeBackTransfers.map((transfer) => transfer.sourceThreadId), + ); + if (pendingMergeBackSourceThreadIds.size > 1) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} has pending merge-back transfers from multiple forks.`, + }); + } + const pendingMergeBackTransfer = latestContextTransfer(pendingMergeBackTransfers); + const supersededMergeBackTransfers = pendingMergeBackTransfers.filter( + (transfer) => transfer.id !== pendingMergeBackTransfer?.id, + ); + const now = yield* DateTime.now; + if ( + !modelSelectionsEqual(projection.thread.modelSelection, modelSelection) || + projection.thread.providerInstanceId !== modelSelection.instanceId + ) { + yield* emit( + events, + command, + )({ + type: + projection.thread.providerInstanceId === modelSelection.instanceId + ? "thread.model-selection-updated" + : "thread.provider-switched", + threadId: command.threadId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...projection.thread, + providerInstanceId: modelSelection.instanceId, + modelSelection, + updatedAt: now, + }, + }); + } + const ordinal = nextRunOrdinal(projection); + const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); + const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); + const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); + const legacyImportItems = + projection.thread.historyOrigin === "v1_import" + ? yield* readHandoffItems(command.threadId, [null]) + : []; + const isProviderSwitch = + activeProviderThread !== undefined && + activeProviderThread.providerInstanceId !== modelSelection.instanceId; + // Account overlays share native history. Selection commands may already + // have updated the app thread, so classify against the native thread's owner. + const canResumeAcrossInstances = + isProviderSwitch && + activeProviderThread.nativeThreadRef !== null && + (yield* providerSwitchService + .plan({ + projection: { + ...projection, + thread: { + ...projection.thread, + modelSelection: { + ...projection.thread.modelSelection, + instanceId: activeProviderThread.providerInstanceId, }, }, - targetModelSelection: modelSelection, - }) - .pipe(mapDispatchError(command))).transition.type === "restart_and_resume"; + }, + targetModelSelection: modelSelection, + }) + .pipe(mapDispatchError(command))).transition.type === "restart_and_resume"; - if ( - pendingForkTransfer === undefined && - pendingMergeBackTransfer === undefined && - !isProviderSwitch - ) { - const adapter = yield* providerAdapters.get(modelSelection.instanceId).pipe( - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: command.commandId, - providerInstanceId: modelSelection.instanceId, - cause, - }), - ), - ); - const providerSessionId = - activeProviderThread?.providerSessionId ?? - (yield* mapDispatchError(command)( - providerSessionIdFor({ - adapter, + if ( + pendingForkTransfer === undefined && + pendingMergeBackTransfer === undefined && + !isProviderSwitch + ) { + const adapter = yield* providerAdapters.get(modelSelection.instanceId).pipe( + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: command.commandId, providerInstanceId: modelSelection.instanceId, - threadId: command.threadId, + cause, }), - )); - const providerThreadId = - activeProviderThread?.id ?? - idAllocator.derive.providerThread({ - driver: adapter.driver, - nativeThreadId: `pending:${runId}`, - }); - const legacyImportHandoff = shouldPrepareLegacyImportHandoff({ - historyOrigin: projection.thread.historyOrigin, - hasCompletedRun: latestCompletedRun !== undefined, - legacyImportItemCount: legacyImportItems.length, - }) - ? yield* contextHandoffService - .prepareLegacyImport({ - threadId: command.threadId, - targetRunId: runId, - toProviderThreadId: providerThreadId, - toProviderInstanceId: modelSelection.instanceId, - items: legacyImportItems, - createdAt: now, - }) - .pipe(mapDispatchError(command)) - : null; - const providerThread: OrchestrationV2ProviderThread = - activeProviderThread === undefined - ? { - id: providerThreadId, - driver: adapter.driver, - providerInstanceId: modelSelection.instanceId, - providerSessionId, - appThreadId: command.threadId, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "not_loaded", - firstRunOrdinal: ordinal, - lastRunOrdinal: ordinal, - handoffIds: legacyImportHandoff === null ? [] : [legacyImportHandoff.id], - forkedFrom: null, - createdAt: now, - updatedAt: now, - } - : { - ...activeProviderThread, - providerSessionId, - lastRunOrdinal: ordinal, - handoffIds: appendContextHandoffId( - activeProviderThread.handoffIds, - legacyImportHandoff?.id ?? null, - ), - updatedAt: now, - }; - const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); - const rootNodeId = idAllocator.derive.rootNode({ runId }); - const checkpointScope = - dispatchMode.type === "defer_start" - ? null - : yield* runtimePolicy - .resolve({ - thread: projection.thread, - modelSelection, - }) - .pipe( - mapDispatchError(command), - Effect.flatMap((resolvedRuntimePolicy) => - checkpointService.prepareRootRunScope({ - threadId: command.threadId, - runId, - rootNodeId, - providerThreadId, - cwd: - resolvedRuntimePolicy.cwd ?? - projection.thread.worktreePath ?? - process.cwd(), - createdAt: now, - }), - ), - mapDispatchError(command), - ); - const run: OrchestrationV2Run = { - id: runId, - threadId: command.threadId, - ordinal, - providerInstanceId: modelSelection.instanceId, - modelSelection, - providerThreadId, - userMessageId: command.messageId, - rootNodeId, - activeAttemptId: attemptId, - status: dispatchMode.type === "defer_start" ? "preparing" : "starting", - queuePosition: null, - requestedAt: now, - startedAt: null, - completedAt: null, - checkpointId: null, - contextHandoffId: legacyImportHandoff?.id ?? null, - ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), - ...(command.restartContinuationOfRunId === undefined - ? {} - : { restartContinuationOfRunId: command.restartContinuationOfRunId }), - ...(dispatchMode.type === "defer_start" && dispatchMode.workspaceStrategy !== undefined - ? { workspacePreparation: dispatchMode.workspaceStrategy } - : {}), - ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), - ...wakeWorkStartedAt(projection.runs, command), - }; - const attempt: OrchestrationV2RunAttempt = { - id: attemptId, - runId, - attemptOrdinal: 1, - rootNodeId, - providerInstanceId: modelSelection.instanceId, - providerThreadId, - providerTurnId: null, - reason: "initial", - status: "pending", - startedAt: null, - completedAt: null, - }; - const rootNode: OrchestrationV2ExecutionNode = { - id: rootNodeId, - threadId: command.threadId, - runId, - parentNodeId: null, - rootNodeId, - kind: "root_turn", - status: "pending", - countsForRun: true, - providerThreadId, - providerTurnId: null, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: checkpointScope?.id ?? null, - startedAt: null, - completedAt: null, - }; - const message: OrchestrationV2ConversationMessage = { - createdBy: command.createdBy, - creationSource: command.creationSource, - ...(command.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: command.scheduledTaskId }), - ...(command.senderThreadId === undefined - ? {} - : { senderThreadId: command.senderThreadId }), - id: command.messageId, - threadId: command.threadId, - runId, - nodeId: rootNodeId, - role: "user", - text: dispatchText, - ...(command.context ? { context: command.context } : {}), - attachments: command.attachments, - streaming: false, - createdAt: now, - updatedAt: now, - ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), - ...(command.notification === undefined ? {} : { notification: command.notification }), - }; - const turnItem: OrchestrationV2TurnItem = { - createdBy: command.createdBy, - creationSource: command.creationSource, - ...(command.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: command.scheduledTaskId }), - ...(command.senderThreadId === undefined - ? {} - : { senderThreadId: command.senderThreadId }), - id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerThreadId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: yield* nextTurnItemOrdinal(projection), - status: "completed", - title: null, - startedAt: now, - completedAt: now, - updatedAt: now, - type: "user_message", - messageId: command.messageId, - inputIntent: "turn_start", - text: dispatchText, - ...(command.context ? { context: command.context } : {}), - attachments: command.attachments, - }; - const preparationTurnItem: OrchestrationV2TurnItem | null = - dispatchMode.type === "defer_start" - ? { - id: idAllocator.derive.turnItemFromProviderItem({ - driver: adapter.driver, - nativeItemId: `workspace-preparation:${runId}`, - }), - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerThreadId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: turnItem.ordinal + 1, - status: "running", - title: WORKSPACE_PREPARATION_INPUT, - startedAt: now, - completedAt: null, - updatedAt: now, - type: "command_execution", - input: WORKSPACE_PREPARATION_INPUT, - } - : null; - const emitEvent = emit(events, command); - yield* emitEvent({ - type: "provider-thread.updated", - threadId: command.threadId, + ), + ); + const providerSessionId = + activeProviderThread?.providerSessionId ?? + (yield* mapDispatchError(command)( + providerSessionIdFor({ + adapter, + providerInstanceId: modelSelection.instanceId, + threadId: command.threadId, + }), + )); + const providerThreadId = + activeProviderThread?.id ?? + idAllocator.derive.providerThread({ driver: adapter.driver, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: providerThread, + nativeThreadId: `pending:${runId}`, }); - if (legacyImportHandoff !== null) { - yield* emitEvent({ - type: "context-handoff.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: legacyImportHandoff, - }); - } - yield* emitEvent({ - type: "run.created", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: run, - }); - yield* completeSourcePlan(now); - yield* emitEvent({ - type: "run-attempt.created", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: attempt, - }); - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: rootNode, - }); - if (checkpointScope !== null) { - yield* emitEvent({ - type: "checkpoint-scope.created", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: checkpointScope, - }); - } - yield* emitEvent({ - type: "message.updated", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: message, - }); - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: notificationTurnItem(turnItem, message, projection.subagents), - }); - if (preparationTurnItem !== null) { - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: preparationTurnItem, - }); - } - const pendingEffect = { - id: `effect:${command.commandId}:provider-turn.start:${runId}`, - commandId: command.commandId, - threadId: command.threadId, - request: { type: "provider-turn.start", runId }, - } satisfies PendingOrchestrationEffectV2; - if (dispatchMode.type !== "defer_start") { - yield* Ref.update(effects, (existing) => [...existing, pendingEffect]); - } - return; - } - const sourceProjection = - pendingForkTransfer === undefined - ? null - : yield* projectionStore - .getThreadRecords(pendingForkTransfer.sourceThreadId, [ - "runs", - "attempts", - "providerThreads", - "providerTurns", - ]) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorProjectionError({ - threadId: pendingForkTransfer.sourceThreadId, - cause, - }), - ), - ); - const sourceRun = - pendingForkTransfer?.sourcePoint.runId === undefined || sourceProjection === null - ? null - : (sourceProjection.runs.find( - (candidate) => candidate.id === pendingForkTransfer.sourcePoint.runId, - ) ?? null); - const sourceProviderThread = - sourceProjection === null || sourceRun === null - ? undefined - : providerThreadForRun(sourceProjection, sourceRun); - const sourceProviderTurnId = - sourceProjection === null || sourceRun === null || sourceRun.activeAttemptId === null - ? undefined - : (latestProviderTurnForAttempt(sourceProjection.providerTurns, sourceRun.activeAttemptId) - ?.id ?? - sourceProjection.attempts.find( - (candidate) => candidate.id === sourceRun.activeAttemptId, - )?.providerTurnId ?? - undefined); - if (pendingForkTransfer !== undefined) { - if (sourceRun === null || sourceProviderThread === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Pending fork transfer ${pendingForkTransfer.id} has no resolvable source provider thread.`, - }); - } - if (pendingForkTransfer.sourceProviderInstanceId === null) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Pending fork transfer ${pendingForkTransfer.id} has no source provider.`, - }); - } - } - - const adapter = yield* providerAdapters.get(modelSelection.instanceId).pipe( - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: command.commandId, - providerInstanceId: modelSelection.instanceId, - cause, - }), - ), - ); - const targetProviderThread = - isProviderSwitch && !canResumeAcrossInstances - ? rootProviderThreadsForProvider(projection, modelSelection.instanceId)[0] - : activeProviderThread; - const providerSessionId = - (canResumeAcrossInstances ? undefined : targetProviderThread?.providerSessionId) ?? - (yield* mapDispatchError(command)( - providerSessionIdFor({ - adapter, - providerInstanceId: modelSelection.instanceId, - threadId: command.threadId, - }), - )); - const existingProviderSession = projection.providerSessions.find( - (candidate) => candidate.id === providerSessionId, - ); - const resolvedRuntimePolicy = yield* runtimePolicy - .resolve({ thread: projection.thread, modelSelection }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); - - const capabilities = yield* adapter.getCapabilities().pipe( - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: command.commandId, - providerInstanceId: modelSelection.instanceId, - cause, - }), - ), - ); - const forkExecution = - pendingForkTransfer === undefined || sourceRun === null - ? null - : yield* enforceCommandPolicy(command)( - commandPolicy.decideForkExecution({ - commandId: command.commandId, - threadId: command.threadId, - providerInstanceId: modelSelection.instanceId, - capabilities, - sameProvider: - pendingForkTransfer.sourceProviderInstanceId === modelSelection.instanceId, - hasStrongNativeSource: sourceProviderThread?.nativeThreadRef?.strength === "strong", - sourceRunStatus: sourceRun.status, - fromSpecificTurn: sourceRun !== null, - }), - ); - const canResolveForkNatively = forkExecution === "native_fork"; - const requiresPortableFork = forkExecution === "portable_context"; - - if (canResolveForkNatively) { - yield* enforceCommandPolicy(command)( - commandPolicy.ensureNativeFork({ - commandId: command.commandId, - threadId: command.threadId, - providerInstanceId: modelSelection.instanceId, - capabilities, - fromSpecificTurn: sourceRun !== null, - }), - ); - } - - const ensuredProviderThread: OrchestrationV2ProviderThread = - targetProviderThread === undefined + const legacyImportHandoff = shouldPrepareLegacyImportHandoff({ + historyOrigin: projection.thread.historyOrigin, + hasCompletedRun: latestCompletedRun !== undefined, + legacyImportItemCount: legacyImportItems.length, + }) + ? yield* contextHandoffService + .prepareLegacyImport({ + threadId: command.threadId, + targetRunId: runId, + toProviderThreadId: providerThreadId, + toProviderInstanceId: modelSelection.instanceId, + items: legacyImportItems, + createdAt: now, + }) + .pipe(mapDispatchError(command)) + : null; + const providerThread: OrchestrationV2ProviderThread = + activeProviderThread === undefined ? { - id: idAllocator.derive.providerThread({ - driver: adapter.driver, - nativeThreadId: `pending:${runId}`, - }), + id: providerThreadId, driver: adapter.driver, providerInstanceId: modelSelection.instanceId, providerSessionId, @@ -5951,340 +5525,70 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio status: "not_loaded", firstRunOrdinal: ordinal, lastRunOrdinal: ordinal, - handoffIds: [], - forkedFrom: - canResolveForkNatively && sourceProviderThread !== undefined - ? { - providerThreadId: sourceProviderThread.id, - ...(sourceProviderTurnId === undefined - ? {} - : { providerTurnId: sourceProviderTurnId }), - } - : null, + handoffIds: legacyImportHandoff === null ? [] : [legacyImportHandoff.id], + forkedFrom: null, createdAt: now, updatedAt: now, } : { - ...targetProviderThread, - providerInstanceId: modelSelection.instanceId, + ...activeProviderThread, providerSessionId, + lastRunOrdinal: ordinal, + handoffIds: appendContextHandoffId( + activeProviderThread.handoffIds, + legacyImportHandoff?.id ?? null, + ), updatedAt: now, }; - const portableForkItems = - !requiresPortableFork || sourceProjection === null || sourceRun === null - ? [] - : yield* readHandoffItems(sourceProjection.thread.id, [ - ...sourceProjection.runs - .filter((run) => run.ordinal <= sourceRun.ordinal) - .map((run) => run.id), - ...(sourceProjection.thread.historyOrigin === "v1_import" ? [null] : []), - ]); - const portableForkHandoff = - !requiresPortableFork || - pendingForkTransfer === undefined || - sourceProjection === null || - sourceRun === null - ? null - : yield* contextHandoffService - .prepareProviderHandoff({ - threadId: command.threadId, - targetRunId: runId, - transferId: pendingForkTransfer.id, - fromProviderThreadIds: - sourceProviderThread === undefined ? [] : [sourceProviderThread.id], - toProviderThreadId: ensuredProviderThread.id, - fromProviderInstanceId: sourceRun.providerInstanceId, - toProviderInstanceId: modelSelection.instanceId, - coveredRunOrdinals: visibleDeltaRunOrdinals(sourceProjection, portableForkItems), - strategy: "full_thread_summary", - items: portableForkItems, - runs: sourceProjection.runs, - createdAt: now, - }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); - const requiresFullProviderSwitchContext = - isProviderSwitch && pendingMergeBackTransfer !== undefined; - const targetLastCompletedRun = - targetProviderThread === undefined - ? undefined - : lastDeliveredRunForProviderThread(projection, targetProviderThread.id); - const providerSwitchCoveredRuns = - !isProviderSwitch || canResumeAcrossInstances || latestHandoffRun === undefined - ? [] - : projection.runs.filter( - (run) => - isHandoffSourceRun(run) && - run.ordinal > - (requiresFullProviderSwitchContext - ? 0 - : (targetLastCompletedRun?.ordinal ?? 0)) && - run.ordinal <= latestHandoffRun.ordinal, - ); - const providerSwitchItems = - providerSwitchCoveredRuns.length === 0 - ? [] - : [ - ...(latestCompletedRun !== undefined && - (targetProviderThread === undefined || requiresFullProviderSwitchContext) - ? legacyImportItems - : []), - ...(yield* readHandoffItems( - command.threadId, - providerSwitchCoveredRuns.map((run) => run.id), - )), - ]; - const providerSwitchTransferId = - providerSwitchCoveredRuns.length === 0 || latestHandoffRun === undefined - ? null - : yield* mapDispatchError(command)( - idAllocator.allocate.contextTransfer({ - sourceThreadId: command.threadId, - targetThreadId: command.threadId, - type: "provider_handoff", - }), - ); - if (providerSwitchTransferId !== null) { - yield* enforceCommandPolicy(command)( - commandPolicy.ensureContextHandoff({ - commandId: command.commandId, - threadId: command.threadId, - providerInstanceId: modelSelection.instanceId, - capabilities, - strategy: - targetProviderThread === undefined || requiresFullProviderSwitchContext - ? "full_thread_summary" - : "delta_context", - }), - ); - } - const providerSwitchHandoff = - providerSwitchTransferId === null || latestHandoffRun === undefined - ? null - : yield* contextHandoffService - .prepareProviderHandoff({ - threadId: command.threadId, - targetRunId: runId, - transferId: providerSwitchTransferId, - fromProviderThreadIds: Array.from( - new Set( - providerSwitchCoveredRuns.flatMap((run) => - run.providerThreadId === null ? [] : [run.providerThreadId], - ), - ), - ), - toProviderThreadId: ensuredProviderThread.id, - fromProviderInstanceId: latestHandoffRun.providerInstanceId, - toProviderInstanceId: modelSelection.instanceId, - coveredRunOrdinals: { - from: providerSwitchCoveredRuns[0]!.ordinal, - to: providerSwitchCoveredRuns.at(-1)!.ordinal, - }, - strategy: - targetProviderThread === undefined || requiresFullProviderSwitchContext - ? "full_thread_summary" - : "delta_since_target_last_seen", - items: providerSwitchItems, - runs: projection.runs, - createdAt: now, - }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); - const legacyImportRecoveryHandoff = - isProviderSwitch && - !canResumeAcrossInstances && - latestCompletedRun === undefined && - legacyImportItems.length > 0 - ? yield* contextHandoffService - .prepareLegacyImport({ - threadId: command.threadId, - targetRunId: runId, - toProviderThreadId: ensuredProviderThread.id, - toProviderInstanceId: modelSelection.instanceId, - items: legacyImportItems, - createdAt: now, - }) - .pipe(mapDispatchError(command)) - : null; - const providerThread: OrchestrationV2ProviderThread = { - ...ensuredProviderThread, - status: "active", - firstRunOrdinal: ensuredProviderThread.firstRunOrdinal ?? ordinal, - lastRunOrdinal: ordinal, - handoffIds: [ - ...ensuredProviderThread.handoffIds, - ...[portableForkHandoff, providerSwitchHandoff, legacyImportRecoveryHandoff].flatMap( - (handoff) => (handoff === null ? [] : [handoff.id]), - ), - ], - updatedAt: now, - }; - const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); const rootNodeId = idAllocator.derive.rootNode({ runId }); - const emitEvent = emit(events, command); - const mergeBackSourceProjection = - pendingMergeBackTransfer === undefined + const checkpointScope = + dispatchMode.type === "defer_start" ? null - : yield* projectionStore - .getThreadRecords(pendingMergeBackTransfer.sourceThreadId, [ - "runs", - "attempts", - "providerThreads", - "providerTurns", - ]) + : yield* runtimePolicy + .resolve({ + thread: projection.thread, + modelSelection, + }) .pipe( - Effect.mapError( - (cause) => - new OrchestratorProjectionError({ - threadId: pendingMergeBackTransfer.sourceThreadId, - cause, - }), + mapDispatchError(command), + Effect.flatMap((resolvedRuntimePolicy) => + checkpointService.prepareRootRunScope({ + threadId: command.threadId, + runId, + rootNodeId, + providerThreadId, + cwd: + resolvedRuntimePolicy.cwd ?? projection.thread.worktreePath ?? process.cwd(), + createdAt: now, + }), ), + mapDispatchError(command), ); - const mergeBackSourceRun = - pendingMergeBackTransfer?.sourcePoint.runId === undefined || - mergeBackSourceProjection === null - ? null - : (mergeBackSourceProjection.runs.find( - (candidate) => candidate.id === pendingMergeBackTransfer.sourcePoint.runId, - ) ?? null); - if (pendingMergeBackTransfer !== undefined && mergeBackSourceRun === null) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Pending merge-back transfer ${pendingMergeBackTransfer.id} has no resolvable source run.`, - }); - } - const mergeBackSourceProviderThread = - mergeBackSourceProjection === null || mergeBackSourceRun === null - ? undefined - : providerThreadForRun(mergeBackSourceProjection, mergeBackSourceRun); - if (pendingMergeBackTransfer !== undefined && mergeBackSourceProviderThread === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Pending merge-back transfer ${pendingMergeBackTransfer.id} has no resolvable source provider thread.`, - }); - } - if (pendingMergeBackTransfer !== undefined) { - yield* enforceCommandPolicy(command)( - commandPolicy.ensureContextHandoff({ - commandId: command.commandId, - threadId: command.threadId, - providerInstanceId: modelSelection.instanceId, - capabilities, - strategy: "fork_delta_context", - }), - ); - } - const mergeBackDeltaItems = - mergeBackSourceProjection === null || mergeBackSourceRun === null - ? [] - : yield* readHandoffItems( - mergeBackSourceProjection.thread.id, - mergeBackSourceProjection.runs - .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) - .map((run) => run.id), - ); - const mergeBackHandoff = - pendingMergeBackTransfer === undefined || - mergeBackSourceProjection === null || - mergeBackSourceRun === null || - mergeBackSourceProviderThread === undefined - ? null - : yield* contextHandoffService - .prepareForkDelta({ - sourceThreadId: pendingMergeBackTransfer.sourceThreadId, - targetThreadId: command.threadId, - targetRunId: runId, - transferId: pendingMergeBackTransfer.id, - fromProviderThreadIds: [mergeBackSourceProviderThread.id], - toProviderThreadId: providerThread.id, - fromProviderInstanceId: mergeBackSourceRun.providerInstanceId, - toProviderInstanceId: modelSelection.instanceId, - coveredRunOrdinals: visibleDeltaRunOrdinals( - mergeBackSourceProjection, - mergeBackDeltaItems, - ), - deltaItems: mergeBackDeltaItems, - createdAt: now, - }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); - const checkpointScope = yield* checkpointService - .prepareRootRunScope({ - threadId: command.threadId, - runId, - rootNodeId, - providerThreadId: providerThread.id, - cwd: - resolvedRuntimePolicy.cwd ?? - existingProviderSession?.cwd ?? - projection.thread.worktreePath ?? - process.cwd(), - createdAt: now, - }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ); const run: OrchestrationV2Run = { id: runId, threadId: command.threadId, ordinal, providerInstanceId: modelSelection.instanceId, modelSelection, - providerThreadId: providerThread.id, + providerThreadId, userMessageId: command.messageId, rootNodeId, activeAttemptId: attemptId, - status: "starting", + status: dispatchMode.type === "defer_start" ? "preparing" : "starting", queuePosition: null, requestedAt: now, startedAt: null, completedAt: null, checkpointId: null, - contextHandoffId: - portableForkHandoff?.id ?? - providerSwitchHandoff?.id ?? - mergeBackHandoff?.id ?? - legacyImportRecoveryHandoff?.id ?? - null, + contextHandoffId: legacyImportHandoff?.id ?? null, ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(dispatchMode.type === "defer_start" && dispatchMode.workspaceStrategy !== undefined + ? { workspacePreparation: dispatchMode.workspaceStrategy } + : {}), ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), ...wakeWorkStartedAt(projection.runs, command), }; @@ -6294,7 +5598,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio attemptOrdinal: 1, rootNodeId, providerInstanceId: modelSelection.instanceId, - providerThreadId: providerThread.id, + providerThreadId, providerTurnId: null, reason: "initial", status: "pending", @@ -6310,11 +5614,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio kind: "root_turn", status: "pending", countsForRun: true, - providerThreadId: providerThread.id, + providerThreadId, providerTurnId: null, nativeItemRef: null, runtimeRequestId: null, - checkpointScopeId: checkpointScope.id, + checkpointScopeId: checkpointScope?.id ?? null, startedAt: null, completedAt: null, }; @@ -6328,416 +5632,1103 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio id: command.messageId, threadId: command.threadId, runId, - nodeId: rootNodeId, - role: "user", - text: dispatchText, - ...(command.context ? { context: command.context } : {}), - attachments: command.attachments, - streaming: false, - createdAt: now, - updatedAt: now, - ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), - ...(command.notification === undefined ? {} : { notification: command.notification }), - }; - const turnItem: OrchestrationV2TurnItem = { - createdBy: command.createdBy, - creationSource: command.creationSource, - ...(command.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: command.scheduledTaskId }), - ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), - id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), + nodeId: rootNodeId, + role: "user", + text: dispatchText, + ...(command.context ? { context: command.context } : {}), + attachments: command.attachments, + streaming: false, + createdAt: now, + updatedAt: now, + ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), + ...(command.notification === undefined ? {} : { notification: command.notification }), + }; + const turnItem: OrchestrationV2TurnItem = { + createdBy: command.createdBy, + creationSource: command.creationSource, + ...(command.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal(projection), + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + messageId: command.messageId, + inputIntent: "turn_start", + text: dispatchText, + ...(command.context ? { context: command.context } : {}), + attachments: command.attachments, + }; + const preparationTurnItem: OrchestrationV2TurnItem | null = + dispatchMode.type === "defer_start" + ? { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: adapter.driver, + nativeItemId: `workspace-preparation:${runId}`, + }), + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: turnItem.ordinal + 1, + status: "running", + title: WORKSPACE_PREPARATION_INPUT, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution", + input: WORKSPACE_PREPARATION_INPUT, + } + : null; + const emitEvent = emit(events, command); + yield* emitEvent({ + type: "provider-thread.updated", + threadId: command.threadId, + driver: adapter.driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: providerThread, + }); + if (legacyImportHandoff !== null) { + yield* emitEvent({ + type: "context-handoff.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: legacyImportHandoff, + }); + } + yield* emitEvent({ + type: "run.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: run, + }); + yield* completeSourcePlan(now); + yield* emitEvent({ + type: "run-attempt.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: attempt, + }); + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: rootNode, + }); + if (checkpointScope !== null) { + yield* emitEvent({ + type: "checkpoint-scope.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: checkpointScope, + }); + } + yield* emitEvent({ + type: "message.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: message, + }); + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: notificationTurnItem(turnItem, message, projection.subagents), + }); + if (preparationTurnItem !== null) { + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: preparationTurnItem, + }); + } + const pendingEffect = { + id: `effect:${command.commandId}:provider-turn.start:${runId}`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "provider-turn.start", runId }, + } satisfies PendingOrchestrationEffectV2; + if (dispatchMode.type !== "defer_start") { + yield* Ref.update(effects, (existing) => [...existing, pendingEffect]); + } + return; + } + const sourceProjection = + pendingForkTransfer === undefined + ? null + : yield* projectionStore + .getThreadRecords(pendingForkTransfer.sourceThreadId, [ + "runs", + "attempts", + "providerThreads", + "providerTurns", + ]) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ + threadId: pendingForkTransfer.sourceThreadId, + cause, + }), + ), + ); + const sourceRun = + pendingForkTransfer?.sourcePoint.runId === undefined || sourceProjection === null + ? null + : (sourceProjection.runs.find( + (candidate) => candidate.id === pendingForkTransfer.sourcePoint.runId, + ) ?? null); + const sourceProviderThread = + sourceProjection === null || sourceRun === null + ? undefined + : providerThreadForRun(sourceProjection, sourceRun); + const sourceProviderTurnId = + sourceProjection === null || sourceRun === null || sourceRun.activeAttemptId === null + ? undefined + : (latestProviderTurnForAttempt(sourceProjection.providerTurns, sourceRun.activeAttemptId) + ?.id ?? + sourceProjection.attempts.find((candidate) => candidate.id === sourceRun.activeAttemptId) + ?.providerTurnId ?? + undefined); + if (pendingForkTransfer !== undefined) { + if (sourceRun === null || sourceProviderThread === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Pending fork transfer ${pendingForkTransfer.id} has no resolvable source provider thread.`, + }); + } + if (pendingForkTransfer.sourceProviderInstanceId === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Pending fork transfer ${pendingForkTransfer.id} has no source provider.`, + }); + } + } + + const adapter = yield* providerAdapters.get(modelSelection.instanceId).pipe( + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: command.commandId, + providerInstanceId: modelSelection.instanceId, + cause, + }), + ), + ); + const targetProviderThread = + isProviderSwitch && !canResumeAcrossInstances + ? rootProviderThreadsForProvider(projection, modelSelection.instanceId)[0] + : activeProviderThread; + const providerSessionId = + (canResumeAcrossInstances ? undefined : targetProviderThread?.providerSessionId) ?? + (yield* mapDispatchError(command)( + providerSessionIdFor({ + adapter, + providerInstanceId: modelSelection.instanceId, + threadId: command.threadId, + }), + )); + const existingProviderSession = projection.providerSessions.find( + (candidate) => candidate.id === providerSessionId, + ); + const resolvedRuntimePolicy = yield* runtimePolicy + .resolve({ thread: projection.thread, modelSelection }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + + const capabilities = yield* adapter.getCapabilities().pipe( + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: command.commandId, + providerInstanceId: modelSelection.instanceId, + cause, + }), + ), + ); + const forkExecution = + pendingForkTransfer === undefined || sourceRun === null + ? null + : yield* enforceCommandPolicy(command)( + commandPolicy.decideForkExecution({ + commandId: command.commandId, + threadId: command.threadId, + providerInstanceId: modelSelection.instanceId, + capabilities, + sameProvider: + pendingForkTransfer.sourceProviderInstanceId === modelSelection.instanceId, + hasStrongNativeSource: sourceProviderThread?.nativeThreadRef?.strength === "strong", + sourceRunStatus: sourceRun.status, + fromSpecificTurn: sourceRun !== null, + }), + ); + const canResolveForkNatively = forkExecution === "native_fork"; + const requiresPortableFork = forkExecution === "portable_context"; + + if (canResolveForkNatively) { + yield* enforceCommandPolicy(command)( + commandPolicy.ensureNativeFork({ + commandId: command.commandId, + threadId: command.threadId, + providerInstanceId: modelSelection.instanceId, + capabilities, + fromSpecificTurn: sourceRun !== null, + }), + ); + } + + const ensuredProviderThread: OrchestrationV2ProviderThread = + targetProviderThread === undefined + ? { + id: idAllocator.derive.providerThread({ + driver: adapter.driver, + nativeThreadId: `pending:${runId}`, + }), + driver: adapter.driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId, + appThreadId: command.threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: ordinal, + lastRunOrdinal: ordinal, + handoffIds: [], + forkedFrom: + canResolveForkNatively && sourceProviderThread !== undefined + ? { + providerThreadId: sourceProviderThread.id, + ...(sourceProviderTurnId === undefined + ? {} + : { providerTurnId: sourceProviderTurnId }), + } + : null, + createdAt: now, + updatedAt: now, + } + : { + ...targetProviderThread, + providerInstanceId: modelSelection.instanceId, + providerSessionId, + updatedAt: now, + }; + const portableForkItems = + !requiresPortableFork || sourceProjection === null || sourceRun === null + ? [] + : yield* readHandoffItems(sourceProjection.thread.id, [ + ...sourceProjection.runs + .filter((run) => run.ordinal <= sourceRun.ordinal) + .map((run) => run.id), + ...(sourceProjection.thread.historyOrigin === "v1_import" ? [null] : []), + ]); + const portableForkHandoff = + !requiresPortableFork || + pendingForkTransfer === undefined || + sourceProjection === null || + sourceRun === null + ? null + : yield* contextHandoffService + .prepareProviderHandoff({ + threadId: command.threadId, + targetRunId: runId, + transferId: pendingForkTransfer.id, + fromProviderThreadIds: + sourceProviderThread === undefined ? [] : [sourceProviderThread.id], + toProviderThreadId: ensuredProviderThread.id, + fromProviderInstanceId: sourceRun.providerInstanceId, + toProviderInstanceId: modelSelection.instanceId, + coveredRunOrdinals: visibleDeltaRunOrdinals(sourceProjection, portableForkItems), + strategy: "full_thread_summary", + items: portableForkItems, + runs: sourceProjection.runs, + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + const requiresFullProviderSwitchContext = + isProviderSwitch && pendingMergeBackTransfer !== undefined; + const targetLastCompletedRun = + targetProviderThread === undefined + ? undefined + : lastDeliveredRunForProviderThread(projection, targetProviderThread.id); + const providerSwitchCoveredRuns = + !isProviderSwitch || canResumeAcrossInstances || latestHandoffRun === undefined + ? [] + : projection.runs.filter( + (run) => + isHandoffSourceRun(run) && + run.ordinal > + (requiresFullProviderSwitchContext ? 0 : (targetLastCompletedRun?.ordinal ?? 0)) && + run.ordinal <= latestHandoffRun.ordinal, + ); + const providerSwitchItems = + providerSwitchCoveredRuns.length === 0 + ? [] + : [ + ...(latestCompletedRun !== undefined && + (targetProviderThread === undefined || requiresFullProviderSwitchContext) + ? legacyImportItems + : []), + ...(yield* readHandoffItems( + command.threadId, + providerSwitchCoveredRuns.map((run) => run.id), + )), + ]; + const providerSwitchTransferId = + providerSwitchCoveredRuns.length === 0 || latestHandoffRun === undefined + ? null + : yield* mapDispatchError(command)( + idAllocator.allocate.contextTransfer({ + sourceThreadId: command.threadId, + targetThreadId: command.threadId, + type: "provider_handoff", + }), + ); + if (providerSwitchTransferId !== null) { + yield* enforceCommandPolicy(command)( + commandPolicy.ensureContextHandoff({ + commandId: command.commandId, + threadId: command.threadId, + providerInstanceId: modelSelection.instanceId, + capabilities, + strategy: + targetProviderThread === undefined || requiresFullProviderSwitchContext + ? "full_thread_summary" + : "delta_context", + }), + ); + } + const providerSwitchHandoff = + providerSwitchTransferId === null || latestHandoffRun === undefined + ? null + : yield* contextHandoffService + .prepareProviderHandoff({ + threadId: command.threadId, + targetRunId: runId, + transferId: providerSwitchTransferId, + fromProviderThreadIds: Array.from( + new Set( + providerSwitchCoveredRuns.flatMap((run) => + run.providerThreadId === null ? [] : [run.providerThreadId], + ), + ), + ), + toProviderThreadId: ensuredProviderThread.id, + fromProviderInstanceId: latestHandoffRun.providerInstanceId, + toProviderInstanceId: modelSelection.instanceId, + coveredRunOrdinals: { + from: providerSwitchCoveredRuns[0]!.ordinal, + to: providerSwitchCoveredRuns.at(-1)!.ordinal, + }, + strategy: + targetProviderThread === undefined || requiresFullProviderSwitchContext + ? "full_thread_summary" + : "delta_since_target_last_seen", + items: providerSwitchItems, + runs: projection.runs, + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + const legacyImportRecoveryHandoff = + isProviderSwitch && + !canResumeAcrossInstances && + latestCompletedRun === undefined && + legacyImportItems.length > 0 + ? yield* contextHandoffService + .prepareLegacyImport({ + threadId: command.threadId, + targetRunId: runId, + toProviderThreadId: ensuredProviderThread.id, + toProviderInstanceId: modelSelection.instanceId, + items: legacyImportItems, + createdAt: now, + }) + .pipe(mapDispatchError(command)) + : null; + const providerThread: OrchestrationV2ProviderThread = { + ...ensuredProviderThread, + status: "active", + firstRunOrdinal: ensuredProviderThread.firstRunOrdinal ?? ordinal, + lastRunOrdinal: ordinal, + handoffIds: [ + ...ensuredProviderThread.handoffIds, + ...[portableForkHandoff, providerSwitchHandoff, legacyImportRecoveryHandoff].flatMap( + (handoff) => (handoff === null ? [] : [handoff.id]), + ), + ], + updatedAt: now, + }; + + const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); + const rootNodeId = idAllocator.derive.rootNode({ runId }); + const emitEvent = emit(events, command); + const mergeBackSourceProjection = + pendingMergeBackTransfer === undefined + ? null + : yield* projectionStore + .getThreadRecords(pendingMergeBackTransfer.sourceThreadId, [ + "runs", + "attempts", + "providerThreads", + "providerTurns", + ]) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ + threadId: pendingMergeBackTransfer.sourceThreadId, + cause, + }), + ), + ); + const mergeBackSourceRun = + pendingMergeBackTransfer?.sourcePoint.runId === undefined || + mergeBackSourceProjection === null + ? null + : (mergeBackSourceProjection.runs.find( + (candidate) => candidate.id === pendingMergeBackTransfer.sourcePoint.runId, + ) ?? null); + if (pendingMergeBackTransfer !== undefined && mergeBackSourceRun === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Pending merge-back transfer ${pendingMergeBackTransfer.id} has no resolvable source run.`, + }); + } + const mergeBackSourceProviderThread = + mergeBackSourceProjection === null || mergeBackSourceRun === null + ? undefined + : providerThreadForRun(mergeBackSourceProjection, mergeBackSourceRun); + if (pendingMergeBackTransfer !== undefined && mergeBackSourceProviderThread === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Pending merge-back transfer ${pendingMergeBackTransfer.id} has no resolvable source provider thread.`, + }); + } + if (pendingMergeBackTransfer !== undefined) { + yield* enforceCommandPolicy(command)( + commandPolicy.ensureContextHandoff({ + commandId: command.commandId, + threadId: command.threadId, + providerInstanceId: modelSelection.instanceId, + capabilities, + strategy: "fork_delta_context", + }), + ); + } + const mergeBackDeltaItems = + mergeBackSourceProjection === null || mergeBackSourceRun === null + ? [] + : yield* readHandoffItems( + mergeBackSourceProjection.thread.id, + mergeBackSourceProjection.runs + .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) + .map((run) => run.id), + ); + const mergeBackHandoff = + pendingMergeBackTransfer === undefined || + mergeBackSourceProjection === null || + mergeBackSourceRun === null || + mergeBackSourceProviderThread === undefined + ? null + : yield* contextHandoffService + .prepareForkDelta({ + sourceThreadId: pendingMergeBackTransfer.sourceThreadId, + targetThreadId: command.threadId, + targetRunId: runId, + transferId: pendingMergeBackTransfer.id, + fromProviderThreadIds: [mergeBackSourceProviderThread.id], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: mergeBackSourceRun.providerInstanceId, + toProviderInstanceId: modelSelection.instanceId, + coveredRunOrdinals: visibleDeltaRunOrdinals( + mergeBackSourceProjection, + mergeBackDeltaItems, + ), + deltaItems: mergeBackDeltaItems, + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + const checkpointScope = yield* checkpointService + .prepareRootRunScope({ + threadId: command.threadId, + runId, + rootNodeId, + providerThreadId: providerThread.id, + cwd: + resolvedRuntimePolicy.cwd ?? + existingProviderSession?.cwd ?? + projection.thread.worktreePath ?? + process.cwd(), + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ); + const run: OrchestrationV2Run = { + id: runId, + threadId: command.threadId, + ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: providerThread.id, + userMessageId: command.messageId, + rootNodeId, + activeAttemptId: attemptId, + status: "starting", + queuePosition: null, + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: + portableForkHandoff?.id ?? + providerSwitchHandoff?.id ?? + mergeBackHandoff?.id ?? + legacyImportRecoveryHandoff?.id ?? + null, + ...(command.sourcePlanRef === undefined ? {} : { sourcePlanRef: command.sourcePlanRef }), + ...(command.restartContinuationOfRunId === undefined + ? {} + : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), + ...wakeWorkStartedAt(projection.runs, command), + }; + const attempt: OrchestrationV2RunAttempt = { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId, + providerInstanceId: modelSelection.instanceId, + providerThreadId: providerThread.id, + providerTurnId: null, + reason: "initial", + status: "pending", + startedAt: null, + completedAt: null, + }; + const rootNode: OrchestrationV2ExecutionNode = { + id: rootNodeId, + threadId: command.threadId, + runId, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "pending", + countsForRun: true, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: checkpointScope.id, + startedAt: null, + completedAt: null, + }; + const message: OrchestrationV2ConversationMessage = { + createdBy: command.createdBy, + creationSource: command.creationSource, + ...(command.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + id: command.messageId, + threadId: command.threadId, + runId, + nodeId: rootNodeId, + role: "user", + text: dispatchText, + ...(command.context ? { context: command.context } : {}), + attachments: command.attachments, + streaming: false, + createdAt: now, + updatedAt: now, + ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), + ...(command.notification === undefined ? {} : { notification: command.notification }), + }; + const turnItem: OrchestrationV2TurnItem = { + createdBy: command.createdBy, + creationSource: command.creationSource, + ...(command.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: command.scheduledTaskId }), + ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: ordinal * 100, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + messageId: command.messageId, + inputIntent: "turn_start", + text: dispatchText, + ...(command.context ? { context: command.context } : {}), + attachments: command.attachments, + }; + const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff; + const handoffSourceRuns = + portableForkHandoff !== null + ? sourceRun === null + ? [] + : [sourceRun] + : providerSwitchHandoff === null + ? mergeBackSourceRun === null + ? [] + : [mergeBackSourceRun] + : providerSwitchCoveredRuns; + const handoffFromModelSelections = Array.from( + new Map( + handoffSourceRuns.map((run) => [ + `${run.modelSelection.instanceId}\0${run.modelSelection.model}`, + run.modelSelection, + ]), + ).values(), + ); + const handoffTurnItem: OrchestrationV2TurnItem | null = + activeHandoff === null + ? null + : { + id: idAllocator.derive.runSignalTurnItem({ + runId, + signal: `context-handoff:${activeHandoff.id}`, + }), + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: ordinal * 100 - 1, + status: "completed", + title: + portableForkHandoff !== null + ? "Fork context" + : providerSwitchHandoff !== null + ? "Provider handoff" + : "Merge-back context", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "handoff", + contextHandoffId: activeHandoff.id, + fromProviderThreadIds: activeHandoff.fromProviderThreadIds, + toProviderThreadId: activeHandoff.toProviderThreadId, + fromProviderInstanceIds: Array.from( + new Set(handoffSourceRuns.map((run) => run.providerInstanceId)), + ), + toProviderInstanceId: modelSelection.instanceId, + fromModelSelections: handoffFromModelSelections, + toModel: modelSelection.model, + strategy: activeHandoff.strategy, + summary: activeHandoff.summaryText, + }; + const nativeForkResolution: OrchestrationV2ContextTransferResolution | null = + !canResolveForkNatively || providerThread.nativeThreadRef === null + ? null + : { + strategy: "native_fork", + providerThreadRef: providerThread.nativeThreadRef, + }; + const portableForkResolution: OrchestrationV2ContextTransferResolution | null = + pendingForkTransfer === undefined || portableForkHandoff === null + ? null + : { + strategy: "portable_context", + contextHandoffId: portableForkHandoff.id, + }; + const mergeBackResolution: OrchestrationV2ContextTransferResolution | null = + pendingMergeBackTransfer === undefined || mergeBackHandoff === null + ? null + : { + strategy: "fork_delta_context", + contextHandoffId: mergeBackHandoff.id, + }; + + if (pendingForkTransfer !== undefined && canResolveForkNatively) { + yield* emitEvent({ + type: "context-transfer.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...pendingForkTransfer, + targetProviderInstanceId: modelSelection.instanceId, + targetRunId: runId, + status: "pending", + resolution: null, + error: null, + updatedAt: now, + }, + }); + } + if (pendingForkTransfer !== undefined && portableForkResolution !== null) { + yield* emitEvent({ + type: "context-transfer.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...pendingForkTransfer, + targetProviderInstanceId: modelSelection.instanceId, + targetRunId: runId, + status: "resolved_portable", + resolution: portableForkResolution, + error: null, + updatedAt: now, + }, + }); + } + yield* emitEvent({ + type: "provider-thread.updated", + threadId: command.threadId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: providerThread, + }); + if (portableForkHandoff !== null) { + yield* emitEvent({ + type: "context-handoff.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: portableForkHandoff, + }); + } + if (legacyImportRecoveryHandoff !== null) { + yield* emitEvent({ + type: "context-handoff.updated", threadId: command.threadId, runId, - nodeId: rootNodeId, - providerThreadId: providerThread.id, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: ordinal * 100, - status: "completed", - title: null, - startedAt: now, - completedAt: now, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: legacyImportRecoveryHandoff, + }); + } + if ( + providerSwitchTransferId !== null && + providerSwitchHandoff !== null && + latestHandoffRun !== undefined + ) { + const transfer: OrchestrationV2ContextTransfer = { + id: providerSwitchTransferId, + type: "provider_handoff", + sourceThreadId: command.threadId, + targetThreadId: command.threadId, + sourcePoint: contextSourcePointForRun(projection, latestHandoffRun), + basePoint: + requiresFullProviderSwitchContext || targetLastCompletedRun === undefined + ? null + : contextSourcePointForRun(projection, targetLastCompletedRun), + sourceProviderInstanceId: latestHandoffRun.providerInstanceId, + targetProviderInstanceId: modelSelection.instanceId, + targetRunId: runId, + status: "consumed", + resolution: { + strategy: + providerSwitchHandoff.strategy === "full_thread_summary" + ? "portable_context" + : "delta_context", + contextHandoffId: providerSwitchHandoff.id, + }, + createdBy: command.createdBy, + error: null, + createdAt: now, updatedAt: now, - type: "user_message", - messageId: command.messageId, - inputIntent: "turn_start", - text: dispatchText, - ...(command.context ? { context: command.context } : {}), - attachments: command.attachments, + consumedAt: now, }; - const activeHandoff = portableForkHandoff ?? mergeBackHandoff ?? providerSwitchHandoff; - const handoffSourceRuns = - portableForkHandoff !== null - ? sourceRun === null - ? [] - : [sourceRun] - : providerSwitchHandoff === null - ? mergeBackSourceRun === null - ? [] - : [mergeBackSourceRun] - : providerSwitchCoveredRuns; - const handoffFromModelSelections = Array.from( - new Map( - handoffSourceRuns.map((run) => [ - `${run.modelSelection.instanceId}\0${run.modelSelection.model}`, - run.modelSelection, - ]), - ).values(), - ); - const handoffTurnItem: OrchestrationV2TurnItem | null = - activeHandoff === null - ? null - : { - id: idAllocator.derive.runSignalTurnItem({ - runId, - signal: `context-handoff:${activeHandoff.id}`, - }), - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerThreadId: providerThread.id, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: ordinal * 100 - 1, - status: "completed", - title: - portableForkHandoff !== null - ? "Fork context" - : providerSwitchHandoff !== null - ? "Provider handoff" - : "Merge-back context", - startedAt: now, - completedAt: now, - updatedAt: now, - type: "handoff", - contextHandoffId: activeHandoff.id, - fromProviderThreadIds: activeHandoff.fromProviderThreadIds, - toProviderThreadId: activeHandoff.toProviderThreadId, - fromProviderInstanceIds: Array.from( - new Set(handoffSourceRuns.map((run) => run.providerInstanceId)), - ), - toProviderInstanceId: modelSelection.instanceId, - fromModelSelections: handoffFromModelSelections, - toModel: modelSelection.model, - strategy: activeHandoff.strategy, - summary: activeHandoff.summaryText, - }; - const nativeForkResolution: OrchestrationV2ContextTransferResolution | null = - !canResolveForkNatively || providerThread.nativeThreadRef === null - ? null - : { - strategy: "native_fork", - providerThreadRef: providerThread.nativeThreadRef, - }; - const portableForkResolution: OrchestrationV2ContextTransferResolution | null = - pendingForkTransfer === undefined || portableForkHandoff === null - ? null - : { - strategy: "portable_context", - contextHandoffId: portableForkHandoff.id, - }; - const mergeBackResolution: OrchestrationV2ContextTransferResolution | null = - pendingMergeBackTransfer === undefined || mergeBackHandoff === null - ? null - : { - strategy: "fork_delta_context", - contextHandoffId: mergeBackHandoff.id, - }; - - if (pendingForkTransfer !== undefined && canResolveForkNatively) { - yield* emitEvent({ - type: "context-transfer.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - ...pendingForkTransfer, - targetProviderInstanceId: modelSelection.instanceId, - targetRunId: runId, - status: "pending", - resolution: null, - error: null, - updatedAt: now, - }, - }); - } - if (pendingForkTransfer !== undefined && portableForkResolution !== null) { - yield* emitEvent({ - type: "context-transfer.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - ...pendingForkTransfer, - targetProviderInstanceId: modelSelection.instanceId, - targetRunId: runId, - status: "resolved_portable", - resolution: portableForkResolution, - error: null, - updatedAt: now, - }, - }); - } yield* emitEvent({ - type: "provider-thread.updated", + type: "context-transfer.created", threadId: command.threadId, + runId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: providerThread, + payload: transfer, }); - if (portableForkHandoff !== null) { - yield* emitEvent({ - type: "context-handoff.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: portableForkHandoff, - }); - } - if (legacyImportRecoveryHandoff !== null) { - yield* emitEvent({ - type: "context-handoff.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: legacyImportRecoveryHandoff, - }); - } - if ( - providerSwitchTransferId !== null && - providerSwitchHandoff !== null && - latestHandoffRun !== undefined - ) { - const transfer: OrchestrationV2ContextTransfer = { - id: providerSwitchTransferId, - type: "provider_handoff", - sourceThreadId: command.threadId, - targetThreadId: command.threadId, - sourcePoint: contextSourcePointForRun(projection, latestHandoffRun), - basePoint: - requiresFullProviderSwitchContext || targetLastCompletedRun === undefined - ? null - : contextSourcePointForRun(projection, targetLastCompletedRun), - sourceProviderInstanceId: latestHandoffRun.providerInstanceId, - targetProviderInstanceId: modelSelection.instanceId, - targetRunId: runId, - status: "consumed", - resolution: { - strategy: - providerSwitchHandoff.strategy === "full_thread_summary" - ? "portable_context" - : "delta_context", - contextHandoffId: providerSwitchHandoff.id, - }, - createdBy: command.createdBy, - error: null, - createdAt: now, - updatedAt: now, - consumedAt: now, - }; - yield* emitEvent({ - type: "context-transfer.created", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: transfer, - }); - yield* emitEvent({ - type: "context-handoff.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: providerSwitchHandoff, - }); - } - if (mergeBackHandoff !== null) { - yield* emitEvent({ - type: "context-handoff.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: mergeBackHandoff, - }); - } - for (const supersededTransfer of supersededMergeBackTransfers) { - yield* emitEvent({ - type: "context-transfer.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - ...supersededTransfer, - status: "superseded", - error: - pendingMergeBackTransfer === undefined - ? "Superseded while consuming merge-back transfer." - : `Superseded by merge-back transfer ${pendingMergeBackTransfer.id}.`, - updatedAt: now, - }, - }); - } - if (pendingMergeBackTransfer !== undefined && mergeBackResolution !== null) { - yield* emitEvent({ - type: "context-transfer.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - ...pendingMergeBackTransfer, - targetProviderInstanceId: modelSelection.instanceId, - targetRunId: runId, - status: "consumed", - resolution: mergeBackResolution, - error: null, - updatedAt: now, - consumedAt: now, - }, - }); - } yield* emitEvent({ - type: "run.created", + type: "context-handoff.updated", threadId: command.threadId, runId, - nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: run, + payload: providerSwitchHandoff, }); - yield* completeSourcePlan(now); + } + if (mergeBackHandoff !== null) { yield* emitEvent({ - type: "run-attempt.created", + type: "context-handoff.updated", threadId: command.threadId, runId, - nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: attempt, + payload: mergeBackHandoff, }); + } + for (const supersededTransfer of supersededMergeBackTransfers) { yield* emitEvent({ - type: "node.updated", + type: "context-transfer.updated", threadId: command.threadId, runId, - nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: rootNode, + payload: { + ...supersededTransfer, + status: "superseded", + error: + pendingMergeBackTransfer === undefined + ? "Superseded while consuming merge-back transfer." + : `Superseded by merge-back transfer ${pendingMergeBackTransfer.id}.`, + updatedAt: now, + }, }); + } + if (pendingMergeBackTransfer !== undefined && mergeBackResolution !== null) { yield* emitEvent({ - type: "checkpoint-scope.created", + type: "context-transfer.updated", threadId: command.threadId, runId, - nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: yield* checkpointService.ensureScope(checkpointScope).pipe( - Effect.mapError( - (cause) => - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause, - }), - ), - ), + payload: { + ...pendingMergeBackTransfer, + targetProviderInstanceId: modelSelection.instanceId, + targetRunId: runId, + status: "consumed", + resolution: mergeBackResolution, + error: null, + updatedAt: now, + consumedAt: now, + }, }); - if (handoffTurnItem !== null) { - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId, - nodeId: rootNodeId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: handoffTurnItem, - }); - } + } + yield* emitEvent({ + type: "run.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: run, + }); + yield* completeSourcePlan(now); + yield* emitEvent({ + type: "run-attempt.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: attempt, + }); + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: rootNode, + }); + yield* emitEvent({ + type: "checkpoint-scope.created", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: yield* checkpointService.ensureScope(checkpointScope).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ), + ), + }); + if (handoffTurnItem !== null) { yield* emitEvent({ - type: "message.updated", + type: "turn-item.updated", threadId: command.threadId, runId, nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: message, + payload: handoffTurnItem, }); + } + yield* emitEvent({ + type: "message.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: message, + }); + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: notificationTurnItem(turnItem, message, projection.subagents), + }); + const forkResolution = nativeForkResolution ?? portableForkResolution; + if (pendingForkTransfer !== undefined && forkResolution !== null) { yield* emitEvent({ - type: "turn-item.updated", + type: "context-transfer.updated", threadId: command.threadId, runId, - nodeId: rootNodeId, providerInstanceId: modelSelection.instanceId, occurredAt: now, - payload: notificationTurnItem(turnItem, message, projection.subagents), + payload: { + ...pendingForkTransfer, + targetProviderInstanceId: modelSelection.instanceId, + targetRunId: runId, + status: "consumed", + resolution: forkResolution, + error: null, + updatedAt: now, + consumedAt: now, + }, }); - const forkResolution = nativeForkResolution ?? portableForkResolution; - if (pendingForkTransfer !== undefined && forkResolution !== null) { - yield* emitEvent({ - type: "context-transfer.updated", - threadId: command.threadId, - runId, - providerInstanceId: modelSelection.instanceId, - occurredAt: now, - payload: { - ...pendingForkTransfer, - targetProviderInstanceId: modelSelection.instanceId, - targetRunId: runId, - status: "consumed", - resolution: forkResolution, - error: null, - updatedAt: now, - consumedAt: now, - }, - }); - } + } - if (canResumeAcrossInstances && activeProviderThread.providerSessionId !== null) { - const previousProviderSessionId = activeProviderThread.providerSessionId; - yield* emitEvent({ - type: "provider-session.detached", + if (canResumeAcrossInstances && activeProviderThread.providerSessionId !== null) { + const previousProviderSessionId = activeProviderThread.providerSessionId; + yield* emitEvent({ + type: "provider-session.detached", + threadId: command.threadId, + driver: activeProviderThread.driver, + providerInstanceId: activeProviderThread.providerInstanceId, + occurredAt: now, + payload: { + providerSessionId: previousProviderSessionId, + detachedAt: now, + reason: "Provider account changed; continuing the native thread.", + }, + }); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:provider-session.detach:${previousProviderSessionId}`, + commandId: command.commandId, threadId: command.threadId, - driver: activeProviderThread.driver, - providerInstanceId: activeProviderThread.providerInstanceId, - occurredAt: now, - payload: { + request: { + type: "provider-session.detach", providerSessionId: previousProviderSessionId, - detachedAt: now, - reason: "Provider account changed; continuing the native thread.", + detail: "Provider account changed; continuing the native thread.", }, - }); - yield* Ref.update(effects, (existing) => [ - ...existing, - { - id: `effect:${command.commandId}:provider-session.detach:${previousProviderSessionId}`, - commandId: command.commandId, - threadId: command.threadId, - request: { - type: "provider-session.detach", - providerSessionId: previousProviderSessionId, - detail: "Provider account changed; continuing the native thread.", - }, - } satisfies PendingOrchestrationEffectV2, - ]); - } - const pendingEffect = { - id: `effect:${command.commandId}:provider-turn.start:${runId}`, - commandId: command.commandId, - threadId: command.threadId, - request: { type: "provider-turn.start", runId }, - } satisfies PendingOrchestrationEffectV2; - yield* Ref.update(effects, (existing) => [...existing, pendingEffect]); - }); + } satisfies PendingOrchestrationEffectV2, + ]); + } + const pendingEffect = { + id: `effect:${command.commandId}:provider-turn.start:${runId}`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "provider-turn.start", runId }, + } satisfies PendingOrchestrationEffectV2; + yield* Ref.update(effects, (existing) => [...existing, pendingEffect]); + }); const dispatchDelegatedTaskRequest = Effect.fn("orchestrationV2.dispatch.delegatedTaskRequest")( function* ( @@ -7481,12 +7472,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); - const dispatchRuntimeRequestRespond = ( - command: Extract, - events: Ref.Ref>, - effects: Ref.Ref>, - ) => - Effect.gen(function* () { + const dispatchRuntimeRequestRespond = Effect.fn("Orchestrator.dispatchRuntimeRequestRespond")( + function* ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { const context = yield* projectionStore .getRuntimeResponseContext(command.threadId, command.requestId) .pipe( @@ -7692,486 +7683,475 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: command.threadId, messageId: MessageId.make(`async-answer:${command.requestId}`), text: replies.join("\n\n"), - attachments: [], - createdBy: "user", - creationSource: "server", - dispatchMode, - }, - events, - effects, - ); - } - if (providerSessionId === null) return; - yield* Ref.update(effects, (existing) => [ - ...existing, - { - id: `effect:${command.commandId}:runtime-request.respond:${command.requestId}`, - commandId: command.commandId, - threadId: command.threadId, - request: { - type: "runtime-request.respond", - providerSessionId, - requestId: command.requestId, - ...(command.decision === undefined ? {} : { decision: command.decision }), - ...(answers === undefined ? {} : { answers }), - }, - } satisfies PendingOrchestrationEffectV2, - ]); - }); - - const dispatchThreadUserInputDismiss = ( - command: Extract, - events: Ref.Ref>, - effects: Ref.Ref>, - ) => - Effect.gen(function* () { - const request = yield* projectionStore - .getRuntimeRequest(command.threadId, command.requestId) - .pipe( - Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), - ); - if (request === undefined || request.status !== "pending") { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "This question has already been answered.", - }); - } - if (request.kind !== "user_input" || request.responseCapability.type !== "message") { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "This question needs an answer. Answer it or stop the turn.", - }); - } - yield* dispatchRuntimeRequestRespond( - { - type: "runtime-request.respond", - commandId: command.commandId, - threadId: command.threadId, - requestId: command.requestId, - decision: "cancel", - }, - events, - effects, - ); - }); - - const dispatchQueuedMessagePromoteToSteer = ( - command: Extract, - events: Ref.Ref>, - effects: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* projectionStore - .getThreadRecords( - command.threadId, - [ - "runs", - "nodes", - "attempts", - "messages", - "providerThreads", - "providerTurns", - "providerSessions", - "turnItems", - "runtimeRequests", - "subagents", - ], - { turnItemTypes: [], messageRoles: ["user"] }, - ) - .pipe( - Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), - ); - if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Thread ${command.threadId} is not active.`, - }); - } - const queuedRun = projection.runs.find((candidate) => candidate.id === command.queuedRunId); - if (queuedRun === undefined || queuedRun.status !== "queued") { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Queued run ${command.queuedRunId} is not queued.`, - }); - } - const queuedRootNode = - queuedRun.rootNodeId === null - ? undefined - : projection.nodes.find((candidate) => candidate.id === queuedRun.rootNodeId); - const queuedAttempt = - queuedRun.activeAttemptId === null - ? undefined - : projection.attempts.find((candidate) => candidate.id === queuedRun.activeAttemptId); - const queuedMessage = projection.messages.find( - (candidate) => candidate.id === queuedRun.userMessageId, - ); - if ( - queuedRootNode === undefined || - queuedAttempt === undefined || - queuedMessage === undefined - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Queued run ${queuedRun.id} is missing message or execution state.`, - }); - } - if ( - queuedMessage.delegatedCompletion !== undefined || - queuedMessage.notification !== undefined - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Automatic completion deliveries cannot be promoted to Steer.", - }); - } - - const now = yield* DateTime.now; - const emitEvent = emit(events, command); - yield* emitEvent({ - type: "run.updated", - threadId: command.threadId, - runId: queuedRun.id, - nodeId: queuedRootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedRun, - status: "cancelled", - queuePosition: null, - completedAt: now, - }, - }); - yield* emitEvent({ - type: "run-attempt.updated", - threadId: command.threadId, - runId: queuedRun.id, - nodeId: queuedRootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedAttempt, - status: "cancelled", - completedAt: now, - }, - }); - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - runId: queuedRun.id, - nodeId: queuedRootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedRootNode, - status: "cancelled", - completedAt: now, - }, - }); - - yield* dispatchSteerIntoRun({ - command, - events, - effects, - projection, - modelSelection: projection.thread.modelSelection, - targetRunId: command.targetRunId, - messageId: queuedMessage.id, - text: queuedMessage.text, - attachments: queuedMessage.attachments, - ...(queuedMessage.context ? { context: queuedMessage.context } : {}), - createdBy: queuedMessage.createdBy, - creationSource: queuedMessage.creationSource, - ...(queuedMessage.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: queuedMessage.scheduledTaskId }), - ...(queuedMessage.senderThreadId === undefined - ? {} - : { senderThreadId: queuedMessage.senderThreadId }), - forceRestart: false, - }); - }); + attachments: [], + createdBy: "user", + creationSource: "server", + dispatchMode, + }, + events, + effects, + ); + } + if (providerSessionId === null) return; + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:runtime-request.respond:${command.requestId}`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "runtime-request.respond", + providerSessionId, + requestId: command.requestId, + ...(command.decision === undefined ? {} : { decision: command.decision }), + ...(answers === undefined ? {} : { answers }), + }, + } satisfies PendingOrchestrationEffectV2, + ]); + }, + ); - const dispatchQueuedRunReorder = ( - command: Extract, - events: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* projectionStore - .getThreadRecords(command.threadId, ["runs", "messages"], { messageRoles: ["user"] }) + const dispatchThreadUserInputDismiss = Effect.fn("Orchestrator.dispatchThreadUserInputDismiss")( + function* ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const request = yield* projectionStore + .getRuntimeRequest(command.threadId, command.requestId) .pipe( Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), ); - const queuedRuns = queuedRunsInDeliveryOrder(projection); - const moving = queuedRuns.find((run) => run.id === command.runId); - if (moving === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Run ${command.runId} is not queued.`, - }); - } - const movingMessage = projection.messages.find( - (candidate) => candidate.id === moving.userMessageId, - ); - if ( - movingMessage?.delegatedCompletion !== undefined || - movingMessage?.notification !== undefined - ) { + if (request === undefined || request.status !== "pending") { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: "Automatic completion deliveries cannot be reordered.", + cause: "This question has already been answered.", }); } - const automaticRuns = queuedRuns.filter((run) => isAutomaticCompletionRun(projection, run)); - if ( - command.beforeRunId !== null && - automaticRuns.some((run) => run.id === command.beforeRunId) - ) { + if (request.kind !== "user_input" || request.responseCapability.type !== "message") { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: "Queued messages cannot be reordered ahead of automatic completion delivery.", + cause: "This question needs an answer. Answer it or stop the turn.", }); } - const reorderableRuns = queuedRuns.filter( - (run) => !isAutomaticCompletionRun(projection, run), - ); - const withoutMoving = reorderableRuns.filter((run) => run.id !== command.runId); - const beforeIndex = - command.beforeRunId === null - ? withoutMoving.length - : withoutMoving.findIndex((run) => run.id === command.beforeRunId); - if (beforeIndex === -1) { - return yield* new OrchestratorDispatchError({ + yield* dispatchRuntimeRequestRespond( + { + type: "runtime-request.respond", commandId: command.commandId, - commandType: command.type, - cause: `Queue target ${command.beforeRunId} is not queued.`, - }); - } - const reordered = [ - ...automaticRuns, - ...withoutMoving.slice(0, beforeIndex), - moving, - ...withoutMoving.slice(beforeIndex), - ]; - const now = yield* DateTime.now; - const emitEvent = emit(events, command); - yield* Effect.forEach( - reordered, - (run, index) => - Effect.gen(function* () { - const queuePosition = index + 1; - if (run.queuePosition === queuePosition) { - return; - } - yield* emitEvent({ - type: "run.updated", - threadId: command.threadId, - runId: run.id, - ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { - ...run, - queuePosition, - }, - }); - }), - { concurrency: 1 }, + threadId: command.threadId, + requestId: command.requestId, + decision: "cancel", + }, + events, + effects, ); + }, + ); + + const dispatchQueuedMessagePromoteToSteer = Effect.fn( + "Orchestrator.dispatchQueuedMessagePromoteToSteer", + )(function* ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const projection = yield* projectionStore + .getThreadRecords( + command.threadId, + [ + "runs", + "nodes", + "attempts", + "messages", + "providerThreads", + "providerTurns", + "providerSessions", + "turnItems", + "runtimeRequests", + "subagents", + ], + { turnItemTypes: [], messageRoles: ["user"] }, + ) + .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId }))); + if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is not active.`, + }); + } + const queuedRun = projection.runs.find((candidate) => candidate.id === command.queuedRunId); + if (queuedRun === undefined || queuedRun.status !== "queued") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Queued run ${command.queuedRunId} is not queued.`, + }); + } + const queuedRootNode = + queuedRun.rootNodeId === null + ? undefined + : projection.nodes.find((candidate) => candidate.id === queuedRun.rootNodeId); + const queuedAttempt = + queuedRun.activeAttemptId === null + ? undefined + : projection.attempts.find((candidate) => candidate.id === queuedRun.activeAttemptId); + const queuedMessage = projection.messages.find( + (candidate) => candidate.id === queuedRun.userMessageId, + ); + if ( + queuedRootNode === undefined || + queuedAttempt === undefined || + queuedMessage === undefined + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Queued run ${queuedRun.id} is missing message or execution state.`, + }); + } + if ( + queuedMessage.delegatedCompletion !== undefined || + queuedMessage.notification !== undefined + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Automatic completion deliveries cannot be promoted to Steer.", + }); + } + + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: queuedRun.id, + nodeId: queuedRootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + ...queuedRun, + status: "cancelled", + queuePosition: null, + completedAt: now, + }, + }); + yield* emitEvent({ + type: "run-attempt.updated", + threadId: command.threadId, + runId: queuedRun.id, + nodeId: queuedRootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + ...queuedAttempt, + status: "cancelled", + completedAt: now, + }, + }); + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + runId: queuedRun.id, + nodeId: queuedRootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + ...queuedRootNode, + status: "cancelled", + completedAt: now, + }, }); - const dispatchQueuedRunCancel = ( - command: Extract, + yield* dispatchSteerIntoRun({ + command, + events, + effects, + projection, + modelSelection: projection.thread.modelSelection, + targetRunId: command.targetRunId, + messageId: queuedMessage.id, + text: queuedMessage.text, + attachments: queuedMessage.attachments, + ...(queuedMessage.context ? { context: queuedMessage.context } : {}), + createdBy: queuedMessage.createdBy, + creationSource: queuedMessage.creationSource, + ...(queuedMessage.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: queuedMessage.scheduledTaskId }), + ...(queuedMessage.senderThreadId === undefined + ? {} + : { senderThreadId: queuedMessage.senderThreadId }), + forceRestart: false, + }); + }); + + const dispatchQueuedRunReorder = Effect.fn("Orchestrator.dispatchQueuedRunReorder")(function* ( + command: Extract, events: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* projectionStore - .getThreadRecords( - command.threadId, - ["runs", "messages", "nodes", "attempts", "subagents", "turnItems"], - { turnItemTypes: [], messageRoles: ["user"] }, - ) - .pipe( - Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), - ); - const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId); - if (queuedRun === undefined || queuedRun.status !== "queued") { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Run ${command.runId} is not queued.`, - }); - } - const queuedMessage = projection.messages.find( - (candidate) => candidate.id === queuedRun.userMessageId, - ); - if (queuedMessage?.delegatedCompletion !== undefined) { - const now = yield* DateTime.now; - yield* disposeDelegatedCompletionCohort({ - command, - events, - projection, - parentRunId: queuedMessage.delegatedCompletion.parentRunId, - disposition: "disposed", - now, - }); - return; - } - const queuedRootNode = - queuedRun.rootNodeId === null - ? undefined - : projection.nodes.find((candidate) => candidate.id === queuedRun.rootNodeId); - const queuedAttempt = - queuedRun.activeAttemptId === null - ? undefined - : projection.attempts.find((candidate) => candidate.id === queuedRun.activeAttemptId); + ) { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "messages"], { messageRoles: ["user"] }) + .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId }))); + const queuedRuns = queuedRunsInDeliveryOrder(projection); + const moving = queuedRuns.find((run) => run.id === command.runId); + if (moving === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} is not queued.`, + }); + } + const movingMessage = projection.messages.find( + (candidate) => candidate.id === moving.userMessageId, + ); + if ( + movingMessage?.delegatedCompletion !== undefined || + movingMessage?.notification !== undefined + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Automatic completion deliveries cannot be reordered.", + }); + } + const automaticRuns = queuedRuns.filter((run) => isAutomaticCompletionRun(projection, run)); + if ( + command.beforeRunId !== null && + automaticRuns.some((run) => run.id === command.beforeRunId) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Queued messages cannot be reordered ahead of automatic completion delivery.", + }); + } + const reorderableRuns = queuedRuns.filter((run) => !isAutomaticCompletionRun(projection, run)); + const withoutMoving = reorderableRuns.filter((run) => run.id !== command.runId); + const beforeIndex = + command.beforeRunId === null + ? withoutMoving.length + : withoutMoving.findIndex((run) => run.id === command.beforeRunId); + if (beforeIndex === -1) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Queue target ${command.beforeRunId} is not queued.`, + }); + } + const reordered = [ + ...automaticRuns, + ...withoutMoving.slice(0, beforeIndex), + moving, + ...withoutMoving.slice(beforeIndex), + ]; + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + yield* Effect.forEach( + reordered, + (run, index) => + Effect.gen(function* () { + const queuePosition = index + 1; + if (run.queuePosition === queuePosition) { + return; + } + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + ...run, + queuePosition, + }, + }); + }), + { concurrency: 1 }, + ); + }); + const dispatchQueuedRunCancel = Effect.fn("Orchestrator.dispatchQueuedRunCancel")(function* ( + command: Extract, + events: Ref.Ref>, + ) { + const projection = yield* projectionStore + .getThreadRecords( + command.threadId, + ["runs", "messages", "nodes", "attempts", "subagents", "turnItems"], + { turnItemTypes: [], messageRoles: ["user"] }, + ) + .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId }))); + const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId); + if (queuedRun === undefined || queuedRun.status !== "queued") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} is not queued.`, + }); + } + const queuedMessage = projection.messages.find( + (candidate) => candidate.id === queuedRun.userMessageId, + ); + if (queuedMessage?.delegatedCompletion !== undefined) { const now = yield* DateTime.now; - const emitEvent = emit(events, command); + yield* disposeDelegatedCompletionCohort({ + command, + events, + projection, + parentRunId: queuedMessage.delegatedCompletion.parentRunId, + disposition: "disposed", + now, + }); + return; + } + const queuedRootNode = + queuedRun.rootNodeId === null + ? undefined + : projection.nodes.find((candidate) => candidate.id === queuedRun.rootNodeId); + const queuedAttempt = + queuedRun.activeAttemptId === null + ? undefined + : projection.attempts.find((candidate) => candidate.id === queuedRun.activeAttemptId); + + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: queuedRun.id, + ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + ...queuedRun, + status: "cancelled", + queuePosition: null, + completedAt: now, + }, + }); + if (queuedAttempt !== undefined && queuedRootNode !== undefined) { yield* emitEvent({ - type: "run.updated", + type: "run-attempt.updated", threadId: command.threadId, runId: queuedRun.id, - ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), + nodeId: queuedRootNode.id, providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, payload: { - ...queuedRun, + ...queuedAttempt, status: "cancelled", - queuePosition: null, completedAt: now, }, }); - if (queuedAttempt !== undefined && queuedRootNode !== undefined) { - yield* emitEvent({ - type: "run-attempt.updated", - threadId: command.threadId, - runId: queuedRun.id, - nodeId: queuedRootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedAttempt, - status: "cancelled", - completedAt: now, - }, - }); - } - if (queuedRootNode !== undefined) { - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - runId: queuedRun.id, - nodeId: queuedRootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedRootNode, - status: "cancelled", - completedAt: now, - }, - }); - } - }); + } + if (queuedRootNode !== undefined) { + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + runId: queuedRun.id, + nodeId: queuedRootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + ...queuedRootNode, + status: "cancelled", + completedAt: now, + }, + }); + } + }); - const dispatchQueuedRunEdit = ( + const dispatchQueuedRunEdit = Effect.fn("Orchestrator.dispatchQueuedRunEdit")(function* ( command: Extract, events: Ref.Ref>, - ) => - Effect.gen(function* () { - if (command.text.trim().length === 0) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Queued run ${command.runId} cannot be edited to an empty message.`, - }); - } - const projection = yield* projectionStore - .getThreadRecords(command.threadId, ["runs", "messages", "turnItems"], { - turnItemTypes: ["user_message"], - messageRoles: ["user"], - }) - .pipe( - Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), - ); - const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId); - if (queuedRun === undefined || queuedRun.status !== "queued") { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Run ${command.runId} is not queued.`, - }); - } - const queuedMessage = projection.messages.find( - (candidate) => candidate.id === queuedRun.userMessageId, - ); - if (queuedMessage === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Queued run ${queuedRun.id} has no user message.`, - }); - } - if ( - queuedMessage.delegatedCompletion !== undefined || - queuedMessage.notification !== undefined - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Automatic completion deliveries cannot be edited.", - }); - } - const queuedTurnItem = projection.turnItems.find( - (candidate) => - candidate.type === "user_message" && candidate.messageId === queuedMessage.id, - ); + ) { + if (command.text.trim().length === 0) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Queued run ${command.runId} cannot be edited to an empty message.`, + }); + } + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "messages", "turnItems"], { + turnItemTypes: ["user_message"], + messageRoles: ["user"], + }) + .pipe(Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId }))); + const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId); + if (queuedRun === undefined || queuedRun.status !== "queued") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} is not queued.`, + }); + } + const queuedMessage = projection.messages.find( + (candidate) => candidate.id === queuedRun.userMessageId, + ); + if (queuedMessage === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Queued run ${queuedRun.id} has no user message.`, + }); + } + if ( + queuedMessage.delegatedCompletion !== undefined || + queuedMessage.notification !== undefined + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Automatic completion deliveries cannot be edited.", + }); + } + const queuedTurnItem = projection.turnItems.find( + (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, + ); - const now = yield* DateTime.now; - const emitEvent = emit(events, command); - const editedAttachments = - command.attachments === undefined ? {} : { attachments: command.attachments }; + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + const editedAttachments = + command.attachments === undefined ? {} : { attachments: command.attachments }; + yield* emitEvent({ + type: "message.updated", + threadId: command.threadId, + runId: queuedRun.id, + ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { + ...queuedMessage, + text: command.text, + ...editedAttachments, + ...(command.context ? { context: command.context } : {}), + updatedAt: now, + }, + }); + if (queuedTurnItem !== undefined && queuedTurnItem.type === "user_message") { yield* emitEvent({ - type: "message.updated", + type: "turn-item.updated", threadId: command.threadId, runId: queuedRun.id, ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, payload: { - ...queuedMessage, + ...queuedTurnItem, text: command.text, ...editedAttachments, ...(command.context ? { context: command.context } : {}), updatedAt: now, }, }); - if (queuedTurnItem !== undefined && queuedTurnItem.type === "user_message") { - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId: queuedRun.id, - ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedTurnItem, - text: command.text, - ...editedAttachments, - ...(command.context ? { context: command.context } : {}), - updatedAt: now, - }, - }); - } - }); + } + }); const loadProjectionForCommand = ( command: OrchestrationV2Command, @@ -8228,11 +8208,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { run, attempt, rootNode, providerThread, preparationItem } as const; }; - const dispatchPreparedRunProgress = ( - command: Extract, - events: Ref.Ref>, - ) => - Effect.gen(function* () { + const dispatchPreparedRunProgress = Effect.fn("Orchestrator.dispatchPreparedRunProgress")( + function* ( + command: Extract, + events: Ref.Ref>, + ) { const projection = yield* loadProjectionForCommand( command, ["runs", "attempts", "nodes", "providerThreads", "turnItems"], @@ -8263,14 +8243,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: now, }, }); - }); + }, + ); - const dispatchPreparedRunRelease = ( - command: Extract, - events: Ref.Ref>, - effects: Ref.Ref>, - ) => - Effect.gen(function* () { + const dispatchPreparedRunRelease = Effect.fn("Orchestrator.dispatchPreparedRunRelease")( + function* ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { const projection = yield* loadProjectionForCommand( command, ["runs", "attempts", "nodes", "providerThreads", "turnItems"], @@ -8352,200 +8333,199 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio request: { type: "provider-turn.start", runId: state.run.id }, } satisfies PendingOrchestrationEffectV2, ]); - }); + }, + ); - const dispatchPreparedRunFail = ( + const dispatchPreparedRunFail = Effect.fn("Orchestrator.dispatchPreparedRunFail")(function* ( command: Extract, events: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* loadProjectionForCommand( - command, - ["runs", "attempts", "nodes", "providerThreads", "turnItems"], - { turnItemTypes: ["command_execution"], turnItemRunId: command.runId }, - ); - const state = preparedRunState(command, projection); - if (state === null) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Run ${command.runId} is not awaiting workspace preparation.`, - }); - } - const now = yield* DateTime.now; - const emitEvent = emit(events, command); - yield* emitEvent({ - type: "run-attempt.updated", - threadId: command.threadId, - runId: state.run.id, - nodeId: state.rootNode.id, - providerInstanceId: state.run.providerInstanceId, - occurredAt: now, - payload: { ...state.attempt, status: "failed", completedAt: now }, - }); - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - runId: state.run.id, - nodeId: state.rootNode.id, - providerInstanceId: state.run.providerInstanceId, - occurredAt: now, - payload: { ...state.rootNode, status: "failed", completedAt: now }, - }); - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId: state.run.id, - nodeId: state.rootNode.id, - providerInstanceId: state.run.providerInstanceId, - occurredAt: now, - payload: { - ...state.preparationItem, - status: "failed", - title: "Workspace preparation failed", - output: command.failure.message, - exitCode: 1, - completedAt: now, - updatedAt: now, - }, - }); - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId: state.run.id, - nodeId: state.rootNode.id, - providerInstanceId: state.run.providerInstanceId, - occurredAt: now, - payload: { - id: idAllocator.derive.turnItemFromProviderItem({ - driver: state.providerThread.driver, - nativeItemId: `workspace-preparation-failure:${state.run.id}`, - }), - threadId: command.threadId, - runId: state.run.id, - nodeId: state.rootNode.id, - providerThreadId: state.providerThread.id, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: yield* nextTurnItemOrdinal(projection), - status: "failed", - title: "Workspace preparation failed", - startedAt: now, - completedAt: now, - updatedAt: now, - type: "error", - failure: { - ...command.failure, - code: ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, - }, - }, + ) { + const projection = yield* loadProjectionForCommand( + command, + ["runs", "attempts", "nodes", "providerThreads", "turnItems"], + { turnItemTypes: ["command_execution"], turnItemRunId: command.runId }, + ); + const state = preparedRunState(command, projection); + if (state === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} is not awaiting workspace preparation.`, }); - yield* emitEvent({ - type: "run.updated", + } + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + yield* emitEvent({ + type: "run-attempt.updated", + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + payload: { ...state.attempt, status: "failed", completedAt: now }, + }); + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + payload: { ...state.rootNode, status: "failed", completedAt: now }, + }); + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + payload: { + ...state.preparationItem, + status: "failed", + title: "Workspace preparation failed", + output: command.failure.message, + exitCode: 1, + completedAt: now, + updatedAt: now, + }, + }); + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + payload: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: state.providerThread.driver, + nativeItemId: `workspace-preparation-failure:${state.run.id}`, + }), threadId: command.threadId, runId: state.run.id, nodeId: state.rootNode.id, - providerInstanceId: state.run.providerInstanceId, - occurredAt: now, - payload: { ...state.run, status: "failed", completedAt: now }, - }); + providerThreadId: state.providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal(projection), + status: "failed", + title: "Workspace preparation failed", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "error", + failure: { + ...command.failure, + code: ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + }, + }, + }); + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + payload: { ...state.run, status: "failed", completedAt: now }, }); + }); - const dispatchPreparedRunRetry = ( + const dispatchPreparedRunRetry = Effect.fn("Orchestrator.dispatchPreparedRunRetry")(function* ( command: Extract, events: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* loadProjectionForCommand( - command, - ["runs", "attempts", "nodes", "providerThreads", "turnItems"], - { turnItemTypes: ["command_execution", "error"], turnItemRunId: command.runId }, - ); - const state = preparedRunState(command, projection); - const failureItem = projection.turnItems.find( - (candidate): candidate is Extract => - candidate.type === "error" && - candidate.status === "failed" && - candidate.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, - ); - if ( - state === null || - state.run.workspacePreparation === undefined || - failureItem === undefined || - projection.thread.archivedAt !== null || - projection.thread.deletedAt !== null - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Run ${command.runId} has no failed workspace preparation to retry.`, - }); - } - if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Another run is active on this thread.", - }); - } - const now = yield* DateTime.now; - const emitEvent = emit(events, command); - const scope = { - threadId: command.threadId, - runId: state.run.id, - nodeId: state.rootNode.id, - providerInstanceId: state.run.providerInstanceId, - occurredAt: now, - }; - yield* emitEvent({ - ...scope, - type: "turn-item.updated", - payload: { ...failureItem, status: "cancelled", updatedAt: now }, - }); - yield* emitEvent({ - ...scope, - type: "turn-item.updated", - payload: { - ...withoutPreparationResult(state.preparationItem), - status: "running", - title: WORKSPACE_PREPARATION_INPUT, - completedAt: null, - updatedAt: now, - }, - }); - yield* emitEvent({ - ...scope, - type: "run-attempt.updated", - payload: { ...state.attempt, status: "pending", completedAt: null }, + ) { + const projection = yield* loadProjectionForCommand( + command, + ["runs", "attempts", "nodes", "providerThreads", "turnItems"], + { turnItemTypes: ["command_execution", "error"], turnItemRunId: command.runId }, + ); + const state = preparedRunState(command, projection); + const failureItem = projection.turnItems.find( + (candidate): candidate is Extract => + candidate.type === "error" && + candidate.status === "failed" && + candidate.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, + ); + if ( + state === null || + state.run.workspacePreparation === undefined || + failureItem === undefined || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} has no failed workspace preparation to retry.`, }); - yield* emitEvent({ - ...scope, - type: "node.updated", - payload: { ...state.rootNode, status: "pending", completedAt: null }, + } + if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Another run is active on this thread.", }); + } + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + const scope = { + threadId: command.threadId, + runId: state.run.id, + nodeId: state.rootNode.id, + providerInstanceId: state.run.providerInstanceId, + occurredAt: now, + }; + yield* emitEvent({ + ...scope, + type: "turn-item.updated", + payload: { ...failureItem, status: "cancelled", updatedAt: now }, + }); + yield* emitEvent({ + ...scope, + type: "turn-item.updated", + payload: { + ...withoutPreparationResult(state.preparationItem), + status: "running", + title: WORKSPACE_PREPARATION_INPUT, + completedAt: null, + updatedAt: now, + }, + }); + yield* emitEvent({ + ...scope, + type: "run-attempt.updated", + payload: { ...state.attempt, status: "pending", completedAt: null }, + }); + yield* emitEvent({ + ...scope, + type: "node.updated", + payload: { ...state.rootNode, status: "pending", completedAt: null }, + }); + yield* emitEvent({ + ...scope, + type: "run.updated", + payload: { ...state.run, status: "preparing", completedAt: null }, + }); + // Messages held when this preparation failed follow the run again. A hold + // from a later run (a stop, say) is not this failure's to release. + const failureOwnsQueue = latestExecutedRun(projection.runs)?.id === state.run.id; + for (const run of projection.runs) { + if (!failureOwnsQueue || run.status !== "queued" || run.queueHeld !== true) continue; yield* emitEvent({ - ...scope, type: "run.updated", - payload: { ...state.run, status: "preparing", completedAt: null }, + threadId: command.threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queueHeld: false }, }); - // Messages held when this preparation failed follow the run again. A hold - // from a later run (a stop, say) is not this failure's to release. - const failureOwnsQueue = latestExecutedRun(projection.runs)?.id === state.run.id; - for (const run of projection.runs) { - if (!failureOwnsQueue || run.status !== "queued" || run.queueHeld !== true) continue; - yield* emitEvent({ - type: "run.updated", - threadId: command.threadId, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...run, queueHeld: false }, - }); - } - }); + } + }); - const settleBackgroundWork = (input: { + const settleBackgroundWork = Effect.fn("Orchestrator.settleBackgroundWork")(function* (input: { readonly command: Extract< OrchestrationV2ServerCommand, { readonly type: "run.interrupt" | "thread.background-work.settle" } @@ -8558,187 +8538,182 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly stoppedProviderThreadId: OrchestrationV2ProviderThread["id"] | null; readonly throughRunOrdinal: number; readonly now: DateTime.Utc; - }) => - Effect.gen(function* () { - const emitEvent = emit(input.events, input.command); - const runOrdinals = new Map(input.projection.runs.map((run) => [run.id, run.ordinal])); - const allocateEventId = () => - idAllocator.allocate.event({ - threadId: input.command.threadId, - commandId: input.command.commandId, - }); - const childThreadIds = new Set(); - const liveness = new Map(); - const hasLiveSession = (providerThreadId: OrchestrationV2ProviderThread["id"]) => - Effect.gen(function* () { - const known = liveness.get(providerThreadId); - if (known !== undefined) return known; - const sessionId = input.projection.providerThreads.find( - (candidate) => candidate.id === providerThreadId, - )?.providerSessionId; - const live = - sessionId !== null && - sessionId !== undefined && - Option.isSome( - yield* providerSessions - .get(sessionId) - .pipe(Effect.orElseSucceed(() => Option.none())), - ); - liveness.set(providerThreadId, live); - return live; - }); - const stillReportsBackgroundWork = Effect.fnUntraced(function* ( - providerThread: OrchestrationV2ProviderThread, + }) { + const emitEvent = emit(input.events, input.command); + const runOrdinals = new Map(input.projection.runs.map((run) => [run.id, run.ordinal])); + const allocateEventId = () => + idAllocator.allocate.event({ + threadId: input.command.threadId, + commandId: input.command.commandId, + }); + const childThreadIds = new Set(); + const liveness = new Map(); + const hasLiveSession = Effect.fn("Orchestrator.hasLiveSession")(function* ( + providerThreadId: OrchestrationV2ProviderThread["id"], + ) { + const known = liveness.get(providerThreadId); + if (known !== undefined) return known; + const sessionId = input.projection.providerThreads.find( + (candidate) => candidate.id === providerThreadId, + )?.providerSessionId; + const live = + sessionId !== null && + sessionId !== undefined && + Option.isSome( + yield* providerSessions.get(sessionId).pipe(Effect.orElseSucceed(() => Option.none())), + ); + liveness.set(providerThreadId, live); + return live; + }); + const stillReportsBackgroundWork = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + ) { + if (providerThread.providerSessionId == null) return false; + const session = yield* providerSessions + .get(providerThread.providerSessionId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(session) || session.value.hasPendingBackgroundWorkForThread === undefined) { + return false; + } + return yield* session.value + .hasPendingBackgroundWorkForThread(providerThread) + // A failed probe cannot confirm that the work ended. + .pipe(Effect.catchCause(() => Effect.succeed(true))); + }); + for (const item of pendingBackgroundTurnItems({ + turnItems: input.projection.turnItems, + runs: input.projection.runs, + })) { + const itemRunOrdinal = item.runId === null ? undefined : runOrdinals.get(item.runId); + if (itemRunOrdinal !== undefined && itemRunOrdinal > input.throughRunOrdinal) continue; + const providerThreadId = item.providerThreadId ?? null; + if ( + providerThreadId !== null && + providerThreadId !== input.stoppedProviderThreadId && + (yield* hasLiveSession(providerThreadId)) ) { - if (providerThread.providerSessionId == null) return false; - const session = yield* providerSessions - .get(providerThread.providerSessionId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if ( - Option.isNone(session) || - session.value.hasPendingBackgroundWorkForThread === undefined - ) { - return false; - } - return yield* session.value - .hasPendingBackgroundWorkForThread(providerThread) - // A failed probe cannot confirm that the work ended. - .pipe(Effect.catchCause(() => Effect.succeed(true))); + continue; + } + const run = input.projection.runs.find((candidate) => candidate.id === item.runId); + yield* emitEvent({ + type: "turn-item.updated", + threadId: input.command.threadId, + ...(item.runId === null ? {} : { runId: item.runId }), + ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), + ...(run === undefined ? {} : { providerInstanceId: run.providerInstanceId }), + occurredAt: input.now, + payload: { + ...item, + status: "interrupted", + completedAt: input.now, + updatedAt: input.now, + }, }); - for (const item of pendingBackgroundTurnItems({ - turnItems: input.projection.turnItems, - runs: input.projection.runs, - })) { - const itemRunOrdinal = item.runId === null ? undefined : runOrdinals.get(item.runId); - if (itemRunOrdinal !== undefined && itemRunOrdinal > input.throughRunOrdinal) continue; - const providerThreadId = item.providerThreadId ?? null; + if (item.type !== "subagent") continue; + // A native subagent died with the process too: end its record, its own + // thread's runless root turn, and the subagents it started there, which + // a provider can record on that thread rather than this one. + const dying: Array<{ + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "nodes" | "subagents" | "turnItems" + >; + readonly subagent: Pick< + Extract, + "subagentId" | "origin" | "childThreadId" | "providerInstanceId" + >; + }> = [{ projection: input.projection, subagent: item }]; + for (const { projection, subagent } of dying) { + const ended = yield* endOrphanedNativeSubagent({ + projection, + subagentId: subagent.subagentId, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...ended]); + const childThreadId = subagent.childThreadId; if ( - providerThreadId !== null && - providerThreadId !== input.stoppedProviderThreadId && - (yield* hasLiveSession(providerThreadId)) + subagent.origin !== "provider_native" || + childThreadId === null || + childThreadIds.has(childThreadId) ) { continue; } - const run = input.projection.runs.find((candidate) => candidate.id === item.runId); - yield* emitEvent({ - type: "turn-item.updated", - threadId: input.command.threadId, - ...(item.runId === null ? {} : { runId: item.runId }), - ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), - ...(run === undefined ? {} : { providerInstanceId: run.providerInstanceId }), - occurredAt: input.now, - payload: { - ...item, - status: "interrupted", - completedAt: input.now, - updatedAt: input.now, - }, - }); - if (item.type !== "subagent") continue; - // A native subagent died with the process too: end its record, its own - // thread's runless root turn, and the subagents it started there, which - // a provider can record on that thread rather than this one. - const dying: Array<{ - readonly projection: Pick< - OrchestrationV2ThreadProjection, - "nodes" | "subagents" | "turnItems" - >; - readonly subagent: Pick< - Extract, - "subagentId" | "origin" | "childThreadId" | "providerInstanceId" - >; - }> = [{ projection: input.projection, subagent: item }]; - for (const { projection, subagent } of dying) { - const ended = yield* endOrphanedNativeSubagent({ - projection, - subagentId: subagent.subagentId, - status: "interrupted", - now: input.now, - emitted: yield* Ref.get(input.events), - allocateEventId, - }).pipe(mapDispatchError(input.command)); - yield* Ref.update(input.events, (events) => [...events, ...ended]); - const childThreadId = subagent.childThreadId; - if ( - subagent.origin !== "provider_native" || - childThreadId === null || - childThreadIds.has(childThreadId) - ) { - continue; - } - childThreadIds.add(childThreadId); - const child = yield* projectionStore - .getThreadRecords(childThreadId, ["nodes", "subagents", "turnItems"], { - // Runless items are the subagent's; any run there is the user's own. - turnItemRunIds: [null], - turnItemStatuses: ["pending", "running", "waiting"], - }) - .pipe( - Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), - mapDispatchError(input.command), - ); - if (child === null) continue; - const childEnded = yield* endRunlessRootTurns({ - threadId: childThreadId, - providerInstanceId: subagent.providerInstanceId, - projection: child, - status: "interrupted", - now: input.now, - emitted: yield* Ref.get(input.events), - allocateEventId, - }).pipe(mapDispatchError(input.command)); - yield* Ref.update(input.events, (events) => [...events, ...childEnded]); - for (const nested of child.turnItems) { - if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); - } - for (const nested of child.subagents) { - if (nested.origin === "provider_native" && nested.runId === null) { - dying.push({ - projection: child, - subagent: { - subagentId: nested.id, - origin: nested.origin, - childThreadId: nested.childThreadId, - providerInstanceId: nested.providerInstanceId, - }, - }); - } + childThreadIds.add(childThreadId); + const child = yield* projectionStore + .getThreadRecords(childThreadId, ["nodes", "subagents", "turnItems"], { + // Runless items are the subagent's; any run there is the user's own. + turnItemRunIds: [null], + turnItemStatuses: ["pending", "running", "waiting"], + }) + .pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + mapDispatchError(input.command), + ); + if (child === null) continue; + const childEnded = yield* endRunlessRootTurns({ + threadId: childThreadId, + providerInstanceId: subagent.providerInstanceId, + projection: child, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...childEnded]); + for (const nested of child.turnItems) { + if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); + } + for (const nested of child.subagents) { + if (nested.origin === "provider_native" && nested.runId === null) { + dying.push({ + projection: child, + subagent: { + subagentId: nested.id, + origin: nested.origin, + childThreadId: nested.childThreadId, + providerInstanceId: nested.providerInstanceId, + }, + }); } } } - for (const providerThread of input.projection.providerThreads) { - if ((providerThread.pendingBackgroundTasks?.length ?? 0) === 0) continue; - if (providerThread.id === input.stoppedProviderThreadId) { - // A refused newer start can take ownership and reject the stopped - // turn's roster clear. Reconcile it without hiding surviving work. - if (yield* stillReportsBackgroundWork(providerThread)) continue; - } else if (yield* hasLiveSession(providerThread.id)) { - continue; - } - yield* emitEvent({ - type: "provider-thread.updated", - threadId: input.command.threadId, - driver: providerThread.driver, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: input.now, - payload: { - ...providerThread, - status: - providerThread.status === "active" && - providerThread.lastRunOrdinal !== null && - providerThread.lastRunOrdinal <= input.throughRunOrdinal - ? "idle" - : providerThread.status, - pendingBackgroundTasks: [], - updatedAt: input.now, - }, - }); + } + for (const providerThread of input.projection.providerThreads) { + if ((providerThread.pendingBackgroundTasks?.length ?? 0) === 0) continue; + if (providerThread.id === input.stoppedProviderThreadId) { + // A refused newer start can take ownership and reject the stopped + // turn's roster clear. Reconcile it without hiding surviving work. + if (yield* stillReportsBackgroundWork(providerThread)) continue; + } else if (yield* hasLiveSession(providerThread.id)) { + continue; } - }); + yield* emitEvent({ + type: "provider-thread.updated", + threadId: input.command.threadId, + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: input.now, + payload: { + ...providerThread, + status: + providerThread.status === "active" && + providerThread.lastRunOrdinal !== null && + providerThread.lastRunOrdinal <= input.throughRunOrdinal + ? "idle" + : providerThread.status, + pendingBackgroundTasks: [], + updatedAt: input.now, + }, + }); + } + }); // A successful Stop may have no terminal event to ingest: the session or // native turn already died, or its final write failed when storage filled. - const settleInterruptedRun = (input: { + const settleInterruptedRun = Effect.fn("Orchestrator.settleInterruptedRun")(function* (input: { readonly command: Extract< OrchestrationV2ServerCommand, { readonly type: "run.interrupt" | "thread.background-work.settle" } @@ -8746,180 +8721,177 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly projection: Pick< OrchestrationV2ThreadProjection, | "runs" - | "attempts" - | "nodes" - | "subagents" - | "runtimeRequests" - | "messages" - | "turnItems" - | "providerThreads" - >; - readonly providerTurn: OrchestrationV2ProviderTurn; - readonly events: Ref.Ref>; - readonly effects: Ref.Ref>; - readonly now: DateTime.Utc; - }) => - Effect.gen(function* () { - const attempt = input.projection.attempts.find( - (candidate) => candidate.id === input.providerTurn.runAttemptId, - ); - const run = input.projection.runs.find((candidate) => candidate.id === attempt?.runId); - const rootNode = input.projection.nodes.find((candidate) => candidate.id === run?.rootNodeId); - if ( - attempt === undefined || - run === undefined || - rootNode === undefined || - run.activeAttemptId !== attempt.id || - run.status !== "running" || - attempt.status !== "running" - ) - return; - const emitEvent = emit(input.events, input.command); - const base = { - threadId: input.command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, - occurredAt: input.now, - }; - if (input.providerTurn.status === "running") { - yield* emitEvent({ - ...base, - type: "provider-turn.updated", - payload: { - ...input.providerTurn, - status: "interrupted", - completedAt: input.now, - }, - }); - } + | "attempts" + | "nodes" + | "subagents" + | "runtimeRequests" + | "messages" + | "turnItems" + | "providerThreads" + >; + readonly providerTurn: OrchestrationV2ProviderTurn; + readonly events: Ref.Ref>; + readonly effects: Ref.Ref>; + readonly now: DateTime.Utc; + }) { + const attempt = input.projection.attempts.find( + (candidate) => candidate.id === input.providerTurn.runAttemptId, + ); + const run = input.projection.runs.find((candidate) => candidate.id === attempt?.runId); + const rootNode = input.projection.nodes.find((candidate) => candidate.id === run?.rootNodeId); + if ( + attempt === undefined || + run === undefined || + rootNode === undefined || + run.activeAttemptId !== attempt.id || + run.status !== "running" || + attempt.status !== "running" + ) + return; + const emitEvent = emit(input.events, input.command); + const base = { + threadId: input.command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: input.now, + }; + if (input.providerTurn.status === "running") { yield* emitEvent({ ...base, - type: "run-attempt.updated", + type: "provider-turn.updated", payload: { - ...attempt, + ...input.providerTurn, status: "interrupted", completedAt: input.now, }, }); - const providerThread = input.projection.providerThreads.find( - (candidate) => candidate.id === input.providerTurn.providerThreadId, - ); - if (providerThread?.status === "active" && providerThread.lastRunOrdinal === run.ordinal) { - yield* emitEvent({ - ...base, - type: "provider-thread.updated", - payload: { ...providerThread, status: "idle", updatedAt: input.now }, - }); - } - for (const message of input.projection.messages) { - if (message.runId !== run.id || !message.streaming) continue; - yield* emitEvent({ - ...base, - ...(message.nodeId === null ? {} : { nodeId: message.nodeId }), - type: "message.updated", - payload: { ...message, streaming: false, updatedAt: input.now }, - }); - } - const delegatedNodeIds = new Set( - input.projection.subagents - .filter((subagent) => subagent.origin === "app_owned") - .map((subagent) => subagent.id), - ); - const cascaded = yield* RunExecutionService.cascadeTerminalizeRunOwnedSubagents({ - run, + } + yield* emitEvent({ + ...base, + type: "run-attempt.updated", + payload: { + ...attempt, status: "interrupted", completedAt: input.now, - allocateEventId: () => - idAllocator.allocate.event({ - threadId: input.command.threadId, - commandId: input.command.commandId, - }), - open: { - subagents: new Map( - input.projection.subagents - .filter((task) => task.runId === run.id && task.origin !== "app_owned") - .map((task) => [task.id, task]), - ), - nodes: new Map( - input.projection.nodes - .filter((node) => node.runId === run.id && !delegatedNodeIds.has(node.id)) - .map((node) => [node.id, node]), - ), - turnItems: new Map(), - childTurnItems: new Map( - input.projection.turnItems - .filter( - (item) => - item.runId === run.id && - (item.type === "assistant_message" || item.type === "reasoning"), - ) - .map((item) => [item.id, item]), - ), - linkedChildThreadIds: new Set([run.threadId]), - }, - }).pipe(mapDispatchError(input.command)); - yield* Ref.update(input.events, (events) => [...events, ...cascaded]); - for (const request of input.projection.runtimeRequests) { - if ( - request.status !== "pending" || - !input.projection.nodes.some( - (node) => node.id === request.nodeId && node.runId === run.id, - ) - ) - continue; - yield* emitEvent({ - ...base, - nodeId: request.nodeId, - type: "runtime-request.updated", - payload: { - ...request, - status: "cancelled", - resolvedAt: input.now, - responseCapability: { type: "not_resumable", reason: "The run was interrupted." }, - }, - }); - } - if (providerThread !== undefined) { - yield* emitEvent({ - ...base, - type: "turn-item.updated", - payload: RunExecutionService.makeInterruptResultTurnItem({ - idAllocator, - run, - rootNode, - providerThread, - completedAt: input.now, - }), - }); - } - const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; + }, + }); + const providerThread = input.projection.providerThreads.find( + (candidate) => candidate.id === input.providerTurn.providerThreadId, + ); + if (providerThread?.status === "active" && providerThread.lastRunOrdinal === run.ordinal) { yield* emitEvent({ ...base, - type: "run.updated", + type: "provider-thread.updated", + payload: { ...providerThread, status: "idle", updatedAt: input.now }, + }); + } + for (const message of input.projection.messages) { + if (message.runId !== run.id || !message.streaming) continue; + yield* emitEvent({ + ...base, + ...(message.nodeId === null ? {} : { nodeId: message.nodeId }), + type: "message.updated", + payload: { ...message, streaming: false, updatedAt: input.now }, + }); + } + const delegatedNodeIds = new Set( + input.projection.subagents + .filter((subagent) => subagent.origin === "app_owned") + .map((subagent) => subagent.id), + ); + const cascaded = yield* RunExecutionService.cascadeTerminalizeRunOwnedSubagents({ + run, + status: "interrupted", + completedAt: input.now, + allocateEventId: () => + idAllocator.allocate.event({ + threadId: input.command.threadId, + commandId: input.command.commandId, + }), + open: { + subagents: new Map( + input.projection.subagents + .filter((task) => task.runId === run.id && task.origin !== "app_owned") + .map((task) => [task.id, task]), + ), + nodes: new Map( + input.projection.nodes + .filter((node) => node.runId === run.id && !delegatedNodeIds.has(node.id)) + .map((node) => [node.id, node]), + ), + turnItems: new Map(), + childTurnItems: new Map( + input.projection.turnItems + .filter( + (item) => + item.runId === run.id && + (item.type === "assistant_message" || item.type === "reasoning"), + ) + .map((item) => [item.id, item]), + ), + linkedChildThreadIds: new Set([run.threadId]), + }, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...cascaded]); + for (const request of input.projection.runtimeRequests) { + if ( + request.status !== "pending" || + !input.projection.nodes.some((node) => node.id === request.nodeId && node.runId === run.id) + ) + continue; + yield* emitEvent({ + ...base, + nodeId: request.nodeId, + type: "runtime-request.updated", payload: { - ...runWithoutDelegatedCompletion, - status: "interrupted", - completedAt: input.now, + ...request, + status: "cancelled", + resolvedAt: input.now, + responseCapability: { type: "not_resumable", reason: "The run was interrupted." }, }, }); - const scopeId = rootNode.checkpointScopeId; - if (scopeId !== null) { - yield* Ref.update(input.effects, (effects) => [ - ...effects, - { - id: `effect:checkpoint.capture:${run.id}`, - commandId: CommandId.make(`command:effect:checkpoint.capture:${run.id}`), - threadId: input.command.threadId, - request: { - type: "checkpoint.capture", - runId: run.id, - scopeId, - }, - } satisfies PendingOrchestrationEffectV2, - ]); - } + } + if (providerThread !== undefined) { + yield* emitEvent({ + ...base, + type: "turn-item.updated", + payload: RunExecutionService.makeInterruptResultTurnItem({ + idAllocator, + run, + rootNode, + providerThread, + completedAt: input.now, + }), + }); + } + const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; + yield* emitEvent({ + ...base, + type: "run.updated", + payload: { + ...runWithoutDelegatedCompletion, + status: "interrupted", + completedAt: input.now, + }, }); + const scopeId = rootNode.checkpointScopeId; + if (scopeId !== null) { + yield* Ref.update(input.effects, (effects) => [ + ...effects, + { + id: `effect:checkpoint.capture:${run.id}`, + commandId: CommandId.make(`command:effect:checkpoint.capture:${run.id}`), + threadId: input.command.threadId, + request: { + type: "checkpoint.capture", + runId: run.id, + scopeId, + }, + } satisfies PendingOrchestrationEffectV2, + ]); + } + }); /** * Its agent can call tools @@ -8993,7 +8965,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - const holdStoppedThread = (input: { + const holdStoppedThread = Effect.fn("Orchestrator.holdStoppedThread")(function* (input: { readonly command: Extract< OrchestrationV2ServerCommand, { readonly type: "run.interrupt" | "thread.stop" } @@ -9002,109 +8974,108 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly projection: Pick; readonly cohortRunIds: ReadonlyArray; readonly now: DateTime.Utc; - }) => - Effect.gen(function* () { - const emitEvent = emit(input.events, input.command); - const { thread, runs, subagents } = input.projection; - const queuedRuns = runs.filter((run) => run.status === "queued"); - const queuedMessages = - queuedRuns.length === 0 - ? [] - : (yield* projectionStore - .getThreadRecords(thread.id, ["messages"], { - messageIds: queuedRuns.map((run) => run.userMessageId), - }) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: thread.id, cause }), - ), - )).messages; - const bufferedMessageIds = new Set( - queuedMessages.filter(isBufferedProviderNotification).map((message) => message.id), - ); - for (const run of runs) { - // A resumed queue can have a lower ordinal than a pending continuation source. - // Stop must reach those sources even when interrupting the resumed run succeeds. - if ( - run.status === "cancelled" && - (yield* awaitsRestartContinuation(run).pipe(mapDispatchError(input.command))) - ) { - yield* markStoppedRun({ ...input, thread, run }); - } - if (run.status !== "queued") continue; - if (bufferedMessageIds.has(run.userMessageId)) { - yield* dispatchQueuedRunCancel( - { - type: "queued-run.cancel", - commandId: input.command.commandId, - threadId: thread.id, - runId: run.id, - }, - input.events, - ); - continue; - } - if (run.queueHeld === true) continue; - yield* emitEvent({ - type: "run.updated", - threadId: thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: input.now, - payload: { ...run, queueHeld: true }, - }); + }) { + const emitEvent = emit(input.events, input.command); + const { thread, runs, subagents } = input.projection; + const queuedRuns = runs.filter((run) => run.status === "queued"); + const queuedMessages = + queuedRuns.length === 0 + ? [] + : (yield* projectionStore + .getThreadRecords(thread.id, ["messages"], { + messageIds: queuedRuns.map((run) => run.userMessageId), + }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: thread.id, cause }), + ), + )).messages; + const bufferedMessageIds = new Set( + queuedMessages.filter(isBufferedProviderNotification).map((message) => message.id), + ); + for (const run of runs) { + // A resumed queue can have a lower ordinal than a pending continuation source. + // Stop must reach those sources even when interrupting the resumed run succeeds. + if ( + run.status === "cancelled" && + (yield* awaitsRestartContinuation(run).pipe(mapDispatchError(input.command))) + ) { + yield* markStoppedRun({ ...input, thread, run }); } - const pullRequests = thread.pullRequests ?? []; - if (pullRequests.some((link) => link.watch !== undefined)) { - yield* emitEvent({ - type: "thread.pull-request-synced", - threadId: thread.id, - providerInstanceId: thread.providerInstanceId, - occurredAt: input.now, - payload: { - ...thread, - pullRequests: pullRequests.map((link) => withPullRequestWatch(link, undefined)), + if (run.status !== "queued") continue; + if (bufferedMessageIds.has(run.userMessageId)) { + yield* dispatchQueuedRunCancel( + { + type: "queued-run.cancel", + commandId: input.command.commandId, + threadId: thread.id, + runId: run.id, }, - }); - } - const cohortRunIds = new Set(input.cohortRunIds); - for (const run of runs) { - if (run.delegatedCompletion?.delivery != null) cohortRunIds.add(run.id); - } - for (const task of subagents) { - const delivery = task.completionDelivery?.state; - if ( - task.origin === "app_owned" && - task.runId !== null && - delivery !== "acknowledged" && - delivery !== "delivered" && - delivery !== "disposed" - ) { - cohortRunIds.add(task.runId); - } + input.events, + ); + continue; } - // Each disposal reads the events the ones before it wrote. - for (const parentRunId of cohortRunIds) { - yield* disposeDelegatedCompletionCohort({ - command: input.command, - events: input.events, - projection: yield* getProjectionWithPendingEvents(thread.id, input.events), - parentRunId, - disposition: "stopped", - now: input.now, - }); + if (run.queueHeld === true) continue; + yield* emitEvent({ + type: "run.updated", + threadId: thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: input.now, + payload: { ...run, queueHeld: true }, + }); + } + const pullRequests = thread.pullRequests ?? []; + if (pullRequests.some((link) => link.watch !== undefined)) { + yield* emitEvent({ + type: "thread.pull-request-synced", + threadId: thread.id, + providerInstanceId: thread.providerInstanceId, + occurredAt: input.now, + payload: { + ...thread, + pullRequests: pullRequests.map((link) => withPullRequestWatch(link, undefined)), + }, + }); + } + const cohortRunIds = new Set(input.cohortRunIds); + for (const run of runs) { + if (run.delegatedCompletion?.delivery != null) cohortRunIds.add(run.id); + } + for (const task of subagents) { + const delivery = task.completionDelivery?.state; + if ( + task.origin === "app_owned" && + task.runId !== null && + delivery !== "acknowledged" && + delivery !== "delivered" && + delivery !== "disposed" + ) { + cohortRunIds.add(task.runId); } - }); + } + // Each disposal reads the events the ones before it wrote. + for (const parentRunId of cohortRunIds) { + yield* disposeDelegatedCompletionCohort({ + command: input.command, + events: input.events, + projection: yield* getProjectionWithPendingEvents(thread.id, input.events), + parentRunId, + disposition: "stopped", + now: input.now, + }); + } + }); - const dispatchBackgroundWorkSettle = ( - command: Extract< - OrchestrationV2InternalCommand, - { readonly type: "thread.background-work.settle" } - >, - events: Ref.Ref>, - effects: Ref.Ref>, - ) => - Effect.gen(function* () { + const dispatchBackgroundWorkSettle = Effect.fn("Orchestrator.dispatchBackgroundWorkSettle")( + function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "thread.background-work.settle" } + >, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { const [projection, stopped] = yield* Effect.all([ projectionStore.getThreadRecords( command.threadId, @@ -9224,417 +9195,333 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (run) => run.id !== stoppedRun.id && (run.status === "preparing" || run.status === "starting" || run.status === "running"), - ) - ) { - return; - } - yield* settleBackgroundWork({ - command, - events, - projection, - stoppedProviderThreadId: command.providerThreadId, - throughRunOrdinal: stoppedRun.ordinal, - now, - }); - }); - - const dispatchSubagentStop = ( - command: Extract, - events: Ref.Ref>, - effects: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* loadProjectionForCommand(command, [ - "runs", - "subagents", - "providerThreads", - "providerTurns", - ]); - const run = projection.runs.find((candidate) => candidate.id === command.runId); - const subagent = projection.subagents.find( - (candidate) => candidate.id === command.subagentId, - ); - if ( - subagent === undefined || - subagent.runId !== command.runId || - subagent.origin !== "provider_native" || - subagent.driver !== "claudeAgent" || - subagent.nativeTaskRef?.strength !== "strong" || - subagent.nativeTaskRef.nativeId === null - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "The requested native subagent cannot be stopped on this run.", - }); - } - if (!["pending", "running", "waiting"].includes(subagent.status)) return; - const providerThread = projection.providerThreads.find( - (candidate) => candidate.id === run?.providerThreadId, - ); - const providerTurn = projection.providerTurns.findLast( - (candidate) => candidate.providerThreadId === providerThread?.id, - ); - if ( - providerThread?.providerSessionId == null || - providerTurn === undefined || - providerThread.driver !== subagent.driver || - providerThread.providerInstanceId !== subagent.providerInstanceId - ) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "The requested native subagent has no owning provider session.", - }); + ) + ) { + return; } - const providerSessionId = providerThread.providerSessionId; - const nativeTaskId = subagent.nativeTaskRef.nativeId; - const now = yield* DateTime.now; - yield* emit( - events, + yield* settleBackgroundWork({ command, - )({ - type: "subagent.updated", - threadId: command.threadId, - runId: command.runId, - nodeId: subagent.id, - providerInstanceId: subagent.providerInstanceId, - occurredAt: now, - payload: { ...subagent, updatedAt: now }, + events, + projection, + stoppedProviderThreadId: command.providerThreadId, + throughRunOrdinal: stoppedRun.ordinal, + now, }); - yield* Ref.update(effects, (existing) => [ - ...existing, - { - id: `effect:${command.commandId}:subagent.stop:${subagent.id}`, - commandId: command.commandId, - threadId: command.threadId, - request: { - type: "provider-turn.interrupt", - providerSessionId, - providerThreadId: providerThread.id, - providerTurnId: providerTurn.id, - subagent: { id: subagent.id, nativeTaskId }, - }, - } satisfies PendingOrchestrationEffectV2, - ]); - return; + }, + ); + + const dispatchSubagentStop = Effect.fn("Orchestrator.dispatchSubagentStop")(function* ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const projection = yield* loadProjectionForCommand(command, [ + "runs", + "subagents", + "providerThreads", + "providerTurns", + ]); + const run = projection.runs.find((candidate) => candidate.id === command.runId); + const subagent = projection.subagents.find((candidate) => candidate.id === command.subagentId); + if ( + subagent === undefined || + subagent.runId !== command.runId || + subagent.origin !== "provider_native" || + subagent.driver !== "claudeAgent" || + subagent.nativeTaskRef?.strength !== "strong" || + subagent.nativeTaskRef.nativeId === null + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The requested native subagent cannot be stopped on this run.", + }); + } + if (!["pending", "running", "waiting"].includes(subagent.status)) return; + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run?.providerThreadId, + ); + const providerTurn = projection.providerTurns.findLast( + (candidate) => candidate.providerThreadId === providerThread?.id, + ); + if ( + providerThread?.providerSessionId == null || + providerTurn === undefined || + providerThread.driver !== subagent.driver || + providerThread.providerInstanceId !== subagent.providerInstanceId + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The requested native subagent has no owning provider session.", + }); + } + const providerSessionId = providerThread.providerSessionId; + const nativeTaskId = subagent.nativeTaskRef.nativeId; + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "subagent.updated", + threadId: command.threadId, + runId: command.runId, + nodeId: subagent.id, + providerInstanceId: subagent.providerInstanceId, + occurredAt: now, + payload: { ...subagent, updatedAt: now }, }); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:subagent.stop:${subagent.id}`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId, + providerThreadId: providerThread.id, + providerTurnId: providerTurn.id, + subagent: { id: subagent.id, nativeTaskId }, + }, + } satisfies PendingOrchestrationEffectV2, + ]); + return; + }); - const dispatchRunInterrupt = ( + const dispatchRunInterrupt = Effect.fn("Orchestrator.dispatchRunInterrupt")(function* ( command: Extract, events: Ref.Ref>, effects: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* loadProjectionForCommand( - command, - [ - "runs", - "nodes", - "providerThreads", - "providerTurns", - "messages", - "attempts", - "turnItems", - "subagents", - "runtimeRequests", + ) { + const projection = yield* loadProjectionForCommand( + command, + [ + "runs", + "nodes", + "providerThreads", + "providerTurns", + "messages", + "attempts", + "turnItems", + "subagents", + "runtimeRequests", + ], + // Thread-wide, like the Waiting strip: a settled thread's background + // work can belong to an earlier run than the one Stop targets. + { + turnItemTypes: [ + "command_execution", + "dynamic_tool", + "subagent", + "assistant_message", + "reasoning", ], - // Thread-wide, like the Waiting strip: a settled thread's background - // work can belong to an earlier run than the one Stop targets. - { - turnItemTypes: [ - "command_execution", - "dynamic_tool", - "subagent", - "assistant_message", - "reasoning", - ], - turnItemStatuses: ["pending", "running", "waiting"], - }, - ); - const run = projection.runs.find((candidate) => candidate.id === command.runId); - const rootNode = - run?.rootNodeId === null - ? undefined - : projection.nodes.find((candidate) => candidate.id === run?.rootNodeId); - const providerThread = - run?.providerThreadId === null - ? undefined - : projection.providerThreads.find((candidate) => candidate.id === run?.providerThreadId); - const hasBackgroundWork = - run?.id === projection.runs.findLast((candidate) => candidate.status !== "queued")?.id && - derivePendingBackgroundWork({ - latestRun: run, - providerThreads: projection.providerThreads, - turnItems: projection.turnItems, - activeProviderThreadId: projection.thread.activeProviderThreadId, - runs: projection.runs, - }).length > 0; - // A failed start has no provider turn. Background work still belongs - // to the provider thread, so Stop reaches its latest accepted turn. - const providerTurn = - projection.providerTurns.findLast( - (candidate) => - candidate.runAttemptId === run?.activeAttemptId && - (run?.status === "running" || candidate.status === "running" || hasBackgroundWork), - ) ?? - (hasBackgroundWork - ? projection.providerTurns.findLast( - (candidate) => candidate.providerThreadId === run?.providerThreadId, - ) - : undefined); - if (run === undefined || rootNode === undefined || providerThread === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Run ${command.runId} is not interruptible.`, - }); - } - const now = yield* DateTime.now; - const completionMessage = projection.messages.find( - (candidate) => candidate.id === run.userMessageId, - ); - const completionCohortRunId = completionMessage?.delegatedCompletion?.parentRunId ?? run.id; - const stopRemainingWork = () => - command.holdQueue === true - ? holdStoppedThread({ - command, - events, - projection, - cohortRunIds: [completionCohortRunId], - now, - }) - : Effect.gen(function* () { - for (const parentRunId of new Set([completionCohortRunId, run.id])) { - yield* disposeDelegatedCompletionCohort({ - command, - events, - projection: yield* getProjectionWithPendingEvents(command.threadId, events), - parentRunId, - disposition: "stopped", - now, - }); - } - }); + turnItemStatuses: ["pending", "running", "waiting"], + }, + ); + const run = projection.runs.find((candidate) => candidate.id === command.runId); + const rootNode = + run?.rootNodeId === null + ? undefined + : projection.nodes.find((candidate) => candidate.id === run?.rootNodeId); + const providerThread = + run?.providerThreadId === null + ? undefined + : projection.providerThreads.find((candidate) => candidate.id === run?.providerThreadId); + const hasBackgroundWork = + run?.id === projection.runs.findLast((candidate) => candidate.status !== "queued")?.id && + derivePendingBackgroundWork({ + latestRun: run, + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: projection.thread.activeProviderThreadId, + runs: projection.runs, + }).length > 0; + // A failed start has no provider turn. Background work still belongs + // to the provider thread, so Stop reaches its latest accepted turn. + const providerTurn = + projection.providerTurns.findLast( + (candidate) => + candidate.runAttemptId === run?.activeAttemptId && + (run?.status === "running" || candidate.status === "running" || hasBackgroundWork), + ) ?? + (hasBackgroundWork + ? projection.providerTurns.findLast( + (candidate) => candidate.providerThreadId === run?.providerThreadId, + ) + : undefined); + if (run === undefined || rootNode === undefined || providerThread === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} is not interruptible.`, + }); + } + const now = yield* DateTime.now; + const completionMessage = projection.messages.find( + (candidate) => candidate.id === run.userMessageId, + ); + const completionCohortRunId = completionMessage?.delegatedCompletion?.parentRunId ?? run.id; + const stopRemainingWork = () => + command.holdQueue === true + ? holdStoppedThread({ + command, + events, + projection, + cohortRunIds: [completionCohortRunId], + now, + }) + : Effect.gen(function* () { + for (const parentRunId of new Set([completionCohortRunId, run.id])) { + yield* disposeDelegatedCompletionCohort({ + command, + events, + projection: yield* getProjectionWithPendingEvents(command.threadId, events), + parentRunId, + disposition: "stopped", + now, + }); + } + }); - const emitEvent = emit(events, command); - const interruptRequestItem: OrchestrationV2TurnItem = { - id: idAllocator.derive.runSignalTurnItem({ - runId: run.id, - signal: "interrupt-request", - }), - threadId: command.threadId, + const emitEvent = emit(events, command); + const interruptRequestItem: OrchestrationV2TurnItem = { + id: idAllocator.derive.runSignalTurnItem({ runId: run.id, - nodeId: rootNode.id, - providerThreadId: providerThread.id, - providerTurnId: providerTurn?.id ?? null, - nativeItemRef: null, - parentItemId: null, - ordinal: yield* nextTurnItemOrdinal(projection), - status: "completed", - title: "Interrupt requested", - startedAt: now, - completedAt: now, - updatedAt: now, - type: "run_interrupt_request", - message: command.reason ?? "Interrupt requested", - }; + signal: "interrupt-request", + }), + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerThreadId: providerThread.id, + providerTurnId: providerTurn?.id ?? null, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal(projection), + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "run_interrupt_request", + message: command.reason ?? "Interrupt requested", + }; - const otherProviderInterrupts: Array = []; - if (hasBackgroundWork || command.holdQueue === true) { - const runOrdinals = new Map( - projection.runs.map((candidate) => [candidate.id, candidate.ordinal]), + const otherProviderInterrupts: Array = []; + if (hasBackgroundWork || command.holdQueue === true) { + const runOrdinals = new Map( + projection.runs.map((candidate) => [candidate.id, candidate.ordinal]), + ); + 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. + const latestTurnByProviderThread = new Map< + OrchestrationV2ProviderThread["id"], + { readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number } + >(); + for (const item of pendingBackgroundTurnItems({ + turnItems: projection.turnItems, + runs: projection.runs, + })) { + const turn = projection.providerTurns.find( + (candidate) => candidate.id === item.providerTurnId, ); - 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. - const latestTurnByProviderThread = new Map< - OrchestrationV2ProviderThread["id"], - { readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number } - >(); - for (const item of pendingBackgroundTurnItems({ - turnItems: projection.turnItems, - runs: projection.runs, - })) { - const turn = projection.providerTurns.find( - (candidate) => candidate.id === item.providerTurnId, - ); - if (turn === undefined || turn.providerThreadId === providerThread.id) continue; - const runOrdinal = runOrdinalOf(item); - const latest = latestTurnByProviderThread.get(turn.providerThreadId); - if (latest === undefined || runOrdinal > latest.runOrdinal) { - latestTurnByProviderThread.set(turn.providerThreadId, { turn, runOrdinal }); - } - } - for (const { turn } of latestTurnByProviderThread.values()) { - const owner = projection.providerThreads.find( - (candidate) => candidate.id === turn.providerThreadId, - ); - if (owner === undefined || owner.providerSessionId === null) continue; - const ownerSession = yield* providerSessions - .get(owner.providerSessionId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isNone(ownerSession)) continue; - otherProviderInterrupts.push({ - id: `effect:${command.commandId}:provider-turn.interrupt:${turn.id}`, - commandId: command.commandId, - threadId: command.threadId, - request: { - type: "provider-turn.interrupt", - providerSessionId: owner.providerSessionId, - providerThreadId: owner.id, - providerTurnId: turn.id, - }, - }); + if (turn === undefined || turn.providerThreadId === providerThread.id) continue; + const runOrdinal = runOrdinalOf(item); + const latest = latestTurnByProviderThread.get(turn.providerThreadId); + if (latest === undefined || runOrdinal > latest.runOrdinal) { + latestTurnByProviderThread.set(turn.providerThreadId, { turn, runOrdinal }); } } - - if ( - providerTurn === undefined && - (run.status === "preparing" || run.status === "starting" || run.status === "running") - ) { - const attempt = projection.attempts.find( - (candidate) => candidate.id === run.activeAttemptId, - ); - if (attempt === undefined) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Run ${command.runId} has no active attempt to interrupt.`, - }); - } - const interruptResultItem: OrchestrationV2TurnItem = { - id: idAllocator.derive.runSignalTurnItem({ - runId: run.id, - signal: "interrupt-result", - }), - threadId: command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerThreadId: providerThread.id, - providerTurnId: null, - nativeItemRef: null, - parentItemId: interruptRequestItem.id, - ordinal: interruptRequestItem.ordinal + 1, - status: "interrupted", - title: "Interrupted", - startedAt: now, - completedAt: now, - updatedAt: now, - type: "run_interrupt_result", - message: "Run interrupted before provider start", - }; - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: interruptRequestItem, - }); - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: interruptResultItem, - }); - const preparationItem = projection.turnItems.find( - ( - candidate, - ): candidate is Extract< - OrchestrationV2TurnItem, - { readonly type: "command_execution" } - > => - candidate.runId === run.id && - candidate.type === "command_execution" && - candidate.input === WORKSPACE_PREPARATION_INPUT && - candidate.status === "running", + for (const { turn } of latestTurnByProviderThread.values()) { + const owner = projection.providerThreads.find( + (candidate) => candidate.id === turn.providerThreadId, ); - if (preparationItem !== undefined) { - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { - ...preparationItem, - status: "interrupted", - title: "Workspace preparation interrupted", - output: command.reason ?? "Interrupted before provider start", - completedAt: now, - updatedAt: now, - }, - }); - } - yield* emitEvent({ - type: "run-attempt.updated", - threadId: command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...attempt, status: "interrupted", completedAt: now }, - }); - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...rootNode, status: "interrupted", completedAt: now }, - }); - yield* emitEvent({ - type: "run.updated", - threadId: command.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...run, status: "interrupted", completedAt: now }, + if (owner === undefined || owner.providerSessionId === null) continue; + const ownerSession = yield* providerSessions + .get(owner.providerSessionId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(ownerSession)) continue; + otherProviderInterrupts.push({ + id: `effect:${command.commandId}:provider-turn.interrupt:${turn.id}`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId: owner.providerSessionId, + providerThreadId: owner.id, + providerTurnId: turn.id, + }, }); - yield* stopRemainingWork(); - yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); - return { - effectTypes: ["provider-turn.start", "provider-turn.restart"], - reason: `Run ${run.id} was interrupted before its provider turn started.`, - } satisfies { - readonly effectTypes: ReadonlyArray; - readonly reason: string; - }; } + } - if (providerTurn === undefined) { + if ( + providerTurn === undefined && + (run.status === "preparing" || run.status === "starting" || run.status === "running") + ) { + const attempt = projection.attempts.find((candidate) => candidate.id === run.activeAttemptId); + if (attempt === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Run ${command.runId} is not interruptible.`, + cause: `Run ${command.runId} has no active attempt to interrupt.`, }); } - const sessionOption = - providerThread.providerSessionId === null - ? Option.none() - : yield* providerSessions.get(providerThread.providerSessionId).pipe( - Effect.mapError( - (cause) => - new OrchestratorProviderAdapterError({ - commandId: command.commandId, - providerInstanceId: run.providerInstanceId, - cause, - }), - ), - ); - if (Option.isNone(sessionOption)) { + const interruptResultItem: OrchestrationV2TurnItem = { + id: idAllocator.derive.runSignalTurnItem({ + runId: run.id, + signal: "interrupt-result", + }), + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: interruptRequestItem.id, + ordinal: interruptRequestItem.ordinal + 1, + status: "interrupted", + title: "Interrupted", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "run_interrupt_result", + message: "Run interrupted before provider start", + }; + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: interruptRequestItem, + }); + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: interruptResultItem, + }); + const preparationItem = projection.turnItems.find( + ( + candidate, + ): candidate is Extract => + candidate.runId === run.id && + candidate.type === "command_execution" && + candidate.input === WORKSPACE_PREPARATION_INPUT && + candidate.status === "running", + ); + if (preparationItem !== undefined) { yield* emitEvent({ type: "turn-item.updated", threadId: command.threadId, @@ -9642,38 +9529,75 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nodeId: rootNode.id, providerInstanceId: run.providerInstanceId, occurredAt: now, - payload: interruptRequestItem, - }); - yield* stopRemainingWork(); - yield* settleInterruptedRun({ command, projection, providerTurn, events, effects, now }); - yield* settleBackgroundWork({ - command, - events, - projection, - stoppedProviderThreadId: providerThread.id, - throughRunOrdinal: run.ordinal, - now, - }); - yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); - return undefined; - } - if (providerThread.providerSessionId === null) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Provider thread ${providerThread.id} has no active provider session.`, + payload: { + ...preparationItem, + status: "interrupted", + title: "Workspace preparation interrupted", + output: command.reason ?? "Interrupted before provider start", + completedAt: now, + updatedAt: now, + }, }); } - const providerSessionId = providerThread.providerSessionId; - yield* enforceCommandPolicy(command)( - commandPolicy.ensureInterrupt({ - commandId: command.commandId, - threadId: command.threadId, - providerInstanceId: run.providerInstanceId, - capabilities: sessionOption.value.providerSession.capabilities, - }), - ); + yield* emitEvent({ + type: "run-attempt.updated", + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...attempt, status: "interrupted", completedAt: now }, + }); + yield* emitEvent({ + type: "node.updated", + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "interrupted", completedAt: now }, + }); + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "interrupted", completedAt: now }, + }); + yield* stopRemainingWork(); + yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); + return { + effectTypes: ["provider-turn.start", "provider-turn.restart"], + reason: `Run ${run.id} was interrupted before its provider turn started.`, + } satisfies { + readonly effectTypes: ReadonlyArray; + readonly reason: string; + }; + } + if (providerTurn === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} is not interruptible.`, + }); + } + const sessionOption = + providerThread.providerSessionId === null + ? Option.none() + : yield* providerSessions.get(providerThread.providerSessionId).pipe( + Effect.mapError( + (cause) => + new OrchestratorProviderAdapterError({ + commandId: command.commandId, + providerInstanceId: run.providerInstanceId, + cause, + }), + ), + ); + if (Option.isNone(sessionOption)) { yield* emitEvent({ type: "turn-item.updated", threadId: command.threadId, @@ -9684,215 +9608,252 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: interruptRequestItem, }); yield* stopRemainingWork(); - yield* Ref.update(effects, (existing) => [ - ...existing, - { - id: `effect:${command.commandId}:provider-turn.interrupt:${providerTurn.id}`, - commandId: command.commandId, - threadId: command.threadId, - request: { - type: "provider-turn.interrupt", - providerSessionId, - providerThreadId: providerThread.id, - providerTurnId: providerTurn.id, - }, - } satisfies PendingOrchestrationEffectV2, - ...otherProviderInterrupts, - ]); + yield* settleInterruptedRun({ command, projection, providerTurn, events, effects, now }); + yield* settleBackgroundWork({ + command, + events, + projection, + stoppedProviderThreadId: providerThread.id, + throughRunOrdinal: run.ordinal, + now, + }); + yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); return undefined; + } + if (providerThread.providerSessionId === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Provider thread ${providerThread.id} has no active provider session.`, + }); + } + const providerSessionId = providerThread.providerSessionId; + yield* enforceCommandPolicy(command)( + commandPolicy.ensureInterrupt({ + commandId: command.commandId, + threadId: command.threadId, + providerInstanceId: run.providerInstanceId, + capabilities: sessionOption.value.providerSession.capabilities, + }), + ); + + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: interruptRequestItem, }); + yield* stopRemainingWork(); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:provider-turn.interrupt:${providerTurn.id}`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId, + providerThreadId: providerThread.id, + providerTurnId: providerTurn.id, + }, + } satisfies PendingOrchestrationEffectV2, + ...otherProviderInterrupts, + ]); + return undefined; + }); - const dispatchThreadStop = ( + const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* ( command: Extract, events: Ref.Ref>, effects: Ref.Ref>, - ) => - Effect.gen(function* () { - const projection = yield* projectionStore - .getThreadRecords(command.threadId, ["runs", "providerThreads", "turnItems", "subagents"], { - turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], - turnItemStatuses: ["pending", "running", "waiting"], - }) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), + ) { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "providerThreads", "turnItems", "subagents"], { + turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], + turnItemStatuses: ["pending", "running", "waiting"], + }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (projection.thread.deletedAt !== null) return undefined; + const cancelIdleOrphans = Effect.gen(function* () { + const pending = yield* Ref.get(events); + const children = new Map< + ThreadId, + ProjectionRecords<"runs" | "providerThreads" | "runtimeRequests" | "turnItems"> | null + >(); + const idleChildren = new Map(); + let nodes: ReadonlyArray | undefined; + for (const recorded of projection.subagents) { + const updated = pending.findLast( + (event) => event.type === "subagent.updated" && event.payload.id === recorded.id, ); - if (projection.thread.deletedAt !== null) return undefined; - const cancelIdleOrphans = Effect.gen(function* () { - const pending = yield* Ref.get(events); - const children = new Map< - ThreadId, - ProjectionRecords<"runs" | "providerThreads" | "runtimeRequests" | "turnItems"> | null - >(); - const idleChildren = new Map(); - let nodes: ReadonlyArray | undefined; - for (const recorded of projection.subagents) { - const updated = pending.findLast( - (event) => event.type === "subagent.updated" && event.payload.id === recorded.id, - ); - const task = updated?.type === "subagent.updated" ? updated.payload : recorded; - if ( - task.origin !== "app_owned" || - task.childThreadId === null || - !isOrchestrationV2WorkActive(task.status) - ) - continue; - const childThreadId = task.childThreadId; - const child = children.has(childThreadId) - ? (children.get(childThreadId) ?? null) - : yield* projectionStore - .getThreadRecords( - childThreadId, - ["runs", "providerThreads", "runtimeRequests", "turnItems"], - { - turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], - turnItemStatuses: ["pending", "running", "waiting"], - }, - ) - .pipe( - Effect.catchTags({ - ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), - }), - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), - ), - ); - children.set(childThreadId, child); - if (child === null) continue; - if ( - child.thread.lineage.parentThreadId !== command.threadId || - child.thread.lineage.relationshipToParent !== "subagent" || - (child.thread.forkedFrom?.type === "node" && - child.thread.forkedFrom.nodeId === task.id) || - child.runs.some((run) => run.delegatedTaskId === task.id) - ) - continue; - const idle = - idleChildren.get(task.childThreadId) ?? - (!child.runs.some((run) => run.status === "queued" || isBlockingRun(run)) && - !child.providerThreads.some( - (thread) => - thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0, - ) && - !child.runtimeRequests.some((request) => request.status === "pending") && - child.turnItems.length === 0 && - Option.isNone(yield* Effect.findFirst(child.runs, awaitsRestartContinuation))); - idleChildren.set(task.childThreadId, idle); - if (!idle) continue; - // Stop can cancel an idle orphan without guessing which completed run owned its result. - const now = yield* DateTime.now; - const emitEvent = emit(events, command); + const task = updated?.type === "subagent.updated" ? updated.payload : recorded; + if ( + task.origin !== "app_owned" || + task.childThreadId === null || + !isOrchestrationV2WorkActive(task.status) + ) + continue; + const childThreadId = task.childThreadId; + const child = children.has(childThreadId) + ? (children.get(childThreadId) ?? null) + : yield* projectionStore + .getThreadRecords( + childThreadId, + ["runs", "providerThreads", "runtimeRequests", "turnItems"], + { + turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], + turnItemStatuses: ["pending", "running", "waiting"], + }, + ) + .pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + ), + ); + children.set(childThreadId, child); + if (child === null) continue; + if ( + child.thread.lineage.parentThreadId !== command.threadId || + child.thread.lineage.relationshipToParent !== "subagent" || + (child.thread.forkedFrom?.type === "node" && + child.thread.forkedFrom.nodeId === task.id) || + child.runs.some((run) => run.delegatedTaskId === task.id) + ) + continue; + const idle = + idleChildren.get(task.childThreadId) ?? + (!child.runs.some((run) => run.status === "queued" || isBlockingRun(run)) && + !child.providerThreads.some( + (thread) => + thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0, + ) && + !child.runtimeRequests.some((request) => request.status === "pending") && + child.turnItems.length === 0 && + Option.isNone(yield* Effect.findFirst(child.runs, awaitsRestartContinuation))); + idleChildren.set(task.childThreadId, idle); + if (!idle) continue; + // Stop can cancel an idle orphan without guessing which completed run owned its result. + const now = yield* DateTime.now; + const emitEvent = emit(events, command); + yield* emitEvent({ + type: "subagent.updated", + threadId: command.threadId, + nodeId: task.id, + occurredAt: now, + payload: { + ...task, + status: "cancelled", + completedAt: now, + updatedAt: now, + completionDelivery: { state: "disposed", observedByRunId: null }, + }, + }); + nodes ??= (yield* projectionStore.getThreadRecords(command.threadId, ["nodes"])).nodes; + const node = nodes.find((candidate) => candidate.id === task.id); + if (node !== undefined) yield* emitEvent({ - type: "subagent.updated", + type: "node.updated", + threadId: command.threadId, + nodeId: node.id, + occurredAt: now, + payload: { ...node, status: "cancelled", completedAt: now }, + }); + for (const item of projection.turnItems) { + if (item.type !== "subagent" || item.subagentId !== task.id) continue; + yield* emitEvent({ + type: "turn-item.updated", threadId: command.threadId, nodeId: task.id, occurredAt: now, - payload: { - ...task, - status: "cancelled", - completedAt: now, - updatedAt: now, - completionDelivery: { state: "disposed", observedByRunId: null }, - }, + payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now }, }); - nodes ??= (yield* projectionStore.getThreadRecords(command.threadId, ["nodes"])).nodes; - const node = nodes.find((candidate) => candidate.id === task.id); - if (node !== undefined) - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - nodeId: node.id, - occurredAt: now, - payload: { ...node, status: "cancelled", completedAt: now }, - }); - for (const item of projection.turnItems) { - if (item.type !== "subagent" || item.subagentId !== task.id) continue; - yield* emitEvent({ - type: "turn-item.updated", - threadId: command.threadId, - nodeId: task.id, - occurredAt: now, - payload: { ...item, status: "cancelled", completedAt: now, updatedAt: now }, - }); - } - } - }).pipe( - Effect.mapError((cause) => - Schema.is(OrchestratorProjectionError)(cause) - ? cause - : new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - const latestRun = projection.runs.findLast((run) => run.status !== "queued"); - const target = - projection.runs.findLast( - (run) => - run.status === "preparing" || run.status === "starting" || run.status === "running", - ) ?? - (derivePendingBackgroundWork({ - latestRun, - providerThreads: projection.providerThreads, - turnItems: projection.turnItems, - activeProviderThreadId: projection.thread.activeProviderThreadId, - runs: projection.runs, - }).length > 0 - ? latestRun - : undefined); - if (target !== undefined) { - const interruptEvents = yield* Ref.make>([]); - const interruptEffects = yield* Ref.make>([]); - const interrupted = yield* Effect.exit( - dispatchRunInterrupt( - { - type: "run.interrupt", - commandId: command.commandId, - threadId: command.threadId, - runId: target.id, - holdQueue: true, - ...(command.reason === undefined ? {} : { reason: command.reason }), - }, - interruptEvents, - interruptEffects, - ), - ); - // A failed interrupt may have written part of its events, so they are kept apart. - if (Exit.isSuccess(interrupted)) { - const written = yield* Ref.get(interruptEvents); - const enqueued = yield* Ref.get(interruptEffects); - yield* Ref.update(events, (existing) => [...existing, ...written]); - yield* Ref.update(effects, (existing) => [...existing, ...enqueued]); - yield* cancelIdleOrphans; - return interrupted.value; } - yield* Effect.logWarning("thread.stop could not interrupt the running turn", { - threadId: command.threadId, - runId: target.id, - cause: interrupted.cause, - }); } - const now = yield* DateTime.now; - // Record that Stop reached the turn it could not interrupt and the thread's last executed - // run, so a late tool call from their agent starts nothing. - const executed = latestExecutedRun(projection.runs); - const reached = new Map(); - if (target !== undefined) reached.set(target.id, target); - if (executed !== null && !hasLiveRun({ runs: [executed] })) - reached.set(executed.id, executed); - for (const run of reached.values()) { - yield* markStoppedRun({ command, events, thread: projection.thread, run, now }); - } - yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now }); - yield* cancelIdleOrphans; - return undefined; - }); + }).pipe( + Effect.mapError((cause) => + Schema.is(OrchestratorProjectionError)(cause) + ? cause + : new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const latestRun = projection.runs.findLast((run) => run.status !== "queued"); + const target = + projection.runs.findLast( + (run) => + run.status === "preparing" || run.status === "starting" || run.status === "running", + ) ?? + (derivePendingBackgroundWork({ + latestRun, + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: projection.thread.activeProviderThreadId, + runs: projection.runs, + }).length > 0 + ? latestRun + : undefined); + if (target !== undefined) { + const interruptEvents = yield* Ref.make>([]); + const interruptEffects = yield* Ref.make>([]); + const interrupted = yield* Effect.exit( + dispatchRunInterrupt( + { + type: "run.interrupt", + commandId: command.commandId, + threadId: command.threadId, + runId: target.id, + holdQueue: true, + ...(command.reason === undefined ? {} : { reason: command.reason }), + }, + interruptEvents, + interruptEffects, + ), + ); + // A failed interrupt may have written part of its events, so they are kept apart. + if (Exit.isSuccess(interrupted)) { + const written = yield* Ref.get(interruptEvents); + const enqueued = yield* Ref.get(interruptEffects); + yield* Ref.update(events, (existing) => [...existing, ...written]); + yield* Ref.update(effects, (existing) => [...existing, ...enqueued]); + yield* cancelIdleOrphans; + return interrupted.value; + } + yield* Effect.logWarning("thread.stop could not interrupt the running turn", { + threadId: command.threadId, + runId: target.id, + cause: interrupted.cause, + }); + } + const now = yield* DateTime.now; + // Record that Stop reached the turn it could not interrupt and the thread's last executed + // run, so a late tool call from their agent starts nothing. + const executed = latestExecutedRun(projection.runs); + const reached = new Map(); + if (target !== undefined) reached.set(target.id, target); + if (executed !== null && !hasLiveRun({ runs: [executed] })) reached.set(executed.id, executed); + for (const run of reached.values()) { + yield* markStoppedRun({ command, events, thread: projection.thread, run, now }); + } + yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now }); + yield* cancelIdleOrphans; + return undefined; + }); - const dispatchCheckpointRollback = ( - command: Extract, - events: Ref.Ref>, - effects: Ref.Ref>, - ) => - Effect.gen(function* () { + const dispatchCheckpointRollback = Effect.fn("Orchestrator.dispatchCheckpointRollback")( + function* ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { const projection = yield* loadProjectionForCommand( command, ["providerThreads", "checkpoints", "checkpointScopes", "runs", "providerTurns", "attempts"], @@ -10061,13 +10022,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, } satisfies PendingOrchestrationEffectV2, ]); - }); + }, + ); - const dispatchCheckpointRollbackFail = ( - command: Extract, - events: Ref.Ref>, - ) => - Effect.gen(function* () { + const dispatchCheckpointRollbackFail = Effect.fn("Orchestrator.dispatchCheckpointRollbackFail")( + function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "checkpoint.rollback.fail" } + >, + events: Ref.Ref>, + ) { const thread = yield* projectionStore .getThread(command.threadId) .pipe(mapDispatchError(command)); @@ -10094,14 +10059,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: now, }, }); - }); + }, + ); /** * Thread lineage and fork origin are immutable, so this * is safe to read without holding either thread's dispatch lock. */ - const appOwnedSubagentParentThreadId = (childThreadId: ThreadId) => - Effect.gen(function* () { + const appOwnedSubagentParentThreadId = Effect.fn("Orchestrator.appOwnedSubagentParentThreadId")( + function* (childThreadId: ThreadId) { const childThread = yield* projectionStore.getThread(childThreadId); const lineage = childThread.lineage; return lineage.relationshipToParent === "subagent" && @@ -10109,7 +10075,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio childThread.forkedFrom?.type === "node" ? lineage.parentThreadId : undefined; - }); + }, + ); const awaitsRestartContinuation = (run: OrchestrationV2Run) => effectOutbox @@ -10336,18 +10303,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio * other (stale policy on the terminal row, or a terminal row regressed to * running). */ - const finalizeAppOwnedSubagentTask = ( - childControls: ProjectionRecords< - "runs" | "messages" | "subagents" | "providerThreads" | "providerTurns" | "attempts" - >, - options: { - readonly settledContinuationOf?: RunId; - readonly syncRunId?: RunId; - readonly task: OrchestrationV2Subagent; - readonly resultRun?: OrchestrationV2Run; - }, - ) => - Effect.gen(function* () { + const finalizeAppOwnedSubagentTask = Effect.fn("Orchestrator.finalizeAppOwnedSubagentTask")( + function* ( + childControls: ProjectionRecords< + "runs" | "messages" | "subagents" | "providerThreads" | "providerTurns" | "attempts" + >, + options: { + readonly settledContinuationOf?: RunId; + readonly syncRunId?: RunId; + readonly task: OrchestrationV2Subagent; + readonly resultRun?: OrchestrationV2Run; + }, + ) { const childThreadId = childControls.thread.id; const forkedFrom = childControls.thread.forkedFrom; if ( @@ -10689,247 +10656,248 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (completionPlan.offer && completionPlan.parentRun !== undefined) { yield* offerDelegatedCompletionDelivery(parentThreadId, completionPlan.parentRun.id); } - }); + }, + ); - const finalizeAppOwnedSubagent = ( + const finalizeAppOwnedSubagent = Effect.fn("Orchestrator.finalizeAppOwnedSubagent")(function* ( childThreadId: ThreadId, options?: { readonly settledContinuationOf?: RunId; readonly syncRunId?: RunId }, - ) => - Effect.gen(function* () { - const childControls = yield* projectionStore.getThreadRecords( - childThreadId, - ["runs", "messages", "subagents", "providerThreads", "providerTurns", "attempts"], - { messageRoles: ["user"] }, - ); - const parentThreadId = childControls.thread.lineage.parentThreadId; + ) { + const childControls = yield* projectionStore.getThreadRecords( + childThreadId, + ["runs", "messages", "subagents", "providerThreads", "providerTurns", "attempts"], + { messageRoles: ["user"] }, + ); + const parentThreadId = childControls.thread.lineage.parentThreadId; + if ( + childControls.thread.lineage.relationshipToParent !== "subagent" || + parentThreadId === null || + childControls.thread.forkedFrom?.type !== "node" + ) + return; + const progress = delegatedTaskProgress(childControls); + const parent = yield* projectionStore.getThreadRecords(parentThreadId, [ + "subagents", + "contextTransfers", + ]); + for (const task of parent.subagents) { if ( - childControls.thread.lineage.relationshipToParent !== "subagent" || - parentThreadId === null || - childControls.thread.forkedFrom?.type !== "node" + task.origin !== "app_owned" || + task.childThreadId !== childThreadId || + (task.id === childControls.thread.forkedFrom.nodeId && + progress.state !== "result_available") || + (task.result !== null && + parent.contextTransfers.some( + (transfer) => + transfer.type === "subagent_result" && transfer.sourceThreadId === childThreadId, + )) ) - return; - const progress = delegatedTaskProgress(childControls); - const parent = yield* projectionStore.getThreadRecords(parentThreadId, [ - "subagents", - "contextTransfers", - ]); - for (const task of parent.subagents) { - if ( - task.origin !== "app_owned" || - task.childThreadId !== childThreadId || - (task.id === childControls.thread.forkedFrom.nodeId && - progress.state !== "result_available") || - (task.result !== null && - parent.contextTransfers.some( - (transfer) => - transfer.type === "subagent_result" && transfer.sourceThreadId === childThreadId, - )) - ) - continue; - yield* finalizeAppOwnedSubagentTask(childControls, { - ...options, - task, - ...(progress.resultRun === undefined ? {} : { resultRun: progress.resultRun }), - }); - } + continue; + yield* finalizeAppOwnedSubagentTask(childControls, { + ...options, + task, + ...(progress.resultRun === undefined ? {} : { resultRun: progress.resultRun }), + }); + } - const { resultRun } = progress; - if (progress.state !== "result_available" || resultRun === undefined) return; - const status = delegatedTaskTerminalStatus(runOutcome(resultRun)); - if (status !== "cancelled" && status !== "interrupted") return; + const { resultRun } = progress; + if (progress.state !== "result_available" || resultRun === undefined) return; + const status = delegatedTaskTerminalStatus(runOutcome(resultRun)); + if (status !== "cancelled" && status !== "interrupted") return; + if ( + yield* childAwaitsRestartContinuation( + childControls.runs, + resultRun, + options?.settledContinuationOf, + ) + ) + return; + // Callers hold the parent lock; dismiss takes the child lock, as follow-up intake does. + const { runtimeRequests } = yield* projectionStore.getThreadRecords(childThreadId, [ + "runtimeRequests", + ]); + const pendingQuestions = runtimeRequests.filter( + (request) => + request.status === "pending" && + request.kind === "user_input" && + request.responseCapability.type === "message", + ); + if (pendingQuestions.length === 0) return; + const { nodes } = yield* projectionStore.getThreadRecords(childThreadId, ["nodes"]); + const resultRunIds = new Set(); + let run: OrchestrationV2Run | undefined = resultRun; + while (run !== undefined && !resultRunIds.has(run.id)) { + resultRunIds.add(run.id); + const previousRunId: RunId | undefined = run.restartContinuationOfRunId; + run = + previousRunId === undefined + ? undefined + : childControls.runs.find((candidate) => candidate.id === previousRunId); + } + for (const request of pendingQuestions) { if ( - yield* childAwaitsRestartContinuation( - childControls.runs, - resultRun, - options?.settledContinuationOf, + nodes.some( + (node) => + node.id === request.nodeId && node.runId !== null && resultRunIds.has(node.runId), ) - ) - return; - // Callers hold the parent lock; dismiss takes the child lock, as follow-up intake does. - const { runtimeRequests } = yield* projectionStore.getThreadRecords(childThreadId, [ - "runtimeRequests", - ]); - const pendingQuestions = runtimeRequests.filter( - (request) => - request.status === "pending" && - request.kind === "user_input" && - request.responseCapability.type === "message", - ); - if (pendingQuestions.length === 0) return; - const { nodes } = yield* projectionStore.getThreadRecords(childThreadId, ["nodes"]); - const resultRunIds = new Set(); - let run: OrchestrationV2Run | undefined = resultRun; - while (run !== undefined && !resultRunIds.has(run.id)) { - resultRunIds.add(run.id); - const previousRunId: RunId | undefined = run.restartContinuationOfRunId; - run = - previousRunId === undefined - ? undefined - : childControls.runs.find((candidate) => candidate.id === previousRunId); - } - for (const request of pendingQuestions) { - if ( - nodes.some( - (node) => - node.id === request.nodeId && node.runId !== null && resultRunIds.has(node.runId), - ) - ) { - yield* dispatchWithReceipt({ - type: "thread.user-input.dismiss", - commandId: CommandId.make(`command:system:delegated-task-question:${request.id}`), - threadId: childThreadId, - requestId: request.id, - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to dismiss task question", { - requestId: request.id, - cause, - }), - ), - ); - } + ) { + yield* dispatchWithReceipt({ + type: "thread.user-input.dismiss", + commandId: CommandId.make(`command:system:delegated-task-question:${request.id}`), + threadId: childThreadId, + requestId: request.id, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to dismiss task question", { + requestId: request.id, + cause, + }), + ), + ); } - }); + } + }); - const finalizeDelegatedCompletionDelivery = (threadId: ThreadId, runId: RunId) => - Effect.gen(function* () { - // Ordinary turns do not own a delegated delivery. Read just their input - // before loading the cohort state needed to settle an actual delivery. - const message = yield* projectionStore.getRunMessage(threadId, runId); - if (message?.delegatedCompletion === undefined) return; - const projection = yield* projectionStore.getThreadRecords( - threadId, - ["runs", "messages", "subagents"], - { messageRoles: ["user"] }, - ); - const deliveryRun = projection.runs.find((candidate) => candidate.id === runId); - const deliveryMessage = - deliveryRun === undefined - ? undefined - : projection.messages.find((candidate) => candidate.id === deliveryRun.userMessageId); - const messageOwnership = deliveryMessage?.delegatedCompletion; - if (deliveryRun === undefined || messageOwnership === undefined) { - return; - } - const parentRun = projection.runs.find( - (candidate) => candidate.id === messageOwnership.parentRunId, - ); - const cohort = parentRun?.delegatedCompletion; - const delivery = cohort?.delivery; + const finalizeDelegatedCompletionDelivery = Effect.fn( + "Orchestrator.finalizeDelegatedCompletionDelivery", + )(function* (threadId: ThreadId, runId: RunId) { + // Ordinary turns do not own a delegated delivery. Read just their input + // before loading the cohort state needed to settle an actual delivery. + const message = yield* projectionStore.getRunMessage(threadId, runId); + if (message?.delegatedCompletion === undefined) return; + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "messages", "subagents"], + { messageRoles: ["user"] }, + ); + const deliveryRun = projection.runs.find((candidate) => candidate.id === runId); + const deliveryMessage = + deliveryRun === undefined + ? undefined + : projection.messages.find((candidate) => candidate.id === deliveryRun.userMessageId); + const messageOwnership = deliveryMessage?.delegatedCompletion; + if (deliveryRun === undefined || messageOwnership === undefined) { + return; + } + const parentRun = projection.runs.find( + (candidate) => candidate.id === messageOwnership.parentRunId, + ); + const cohort = parentRun?.delegatedCompletion; + const delivery = cohort?.delivery; + if ( + parentRun === undefined || + cohort === undefined || + delivery === null || + delivery === undefined || + delivery.messageId !== deliveryRun.userMessageId || + delivery.generation !== messageOwnership.generation + ) { + return; + } + + const now = yield* DateTime.now; + const nextTaskStates = new Map< + OrchestrationV2Subagent["id"], + OrchestrationV2Subagent["completionDelivery"] + >(); + for (const task of projection.subagents) { if ( - parentRun === undefined || - cohort === undefined || - delivery === null || - delivery === undefined || - delivery.messageId !== deliveryRun.userMessageId || - delivery.generation !== messageOwnership.generation + task.origin !== "app_owned" || + task.runId !== parentRun.id || + !delivery.taskIds.includes(task.id) || + task.completionDelivery?.state !== "claimed" ) { - return; + continue; } - - const now = yield* DateTime.now; - const nextTaskStates = new Map< - OrchestrationV2Subagent["id"], - OrchestrationV2Subagent["completionDelivery"] - >(); - for (const task of projection.subagents) { - if ( - task.origin !== "app_owned" || - task.runId !== parentRun.id || - !delivery.taskIds.includes(task.id) || - task.completionDelivery?.state !== "claimed" - ) { - continue; + nextTaskStates.set(task.id, { + state: deliveryRun.status === "cancelled" ? "pending" : "delivered", + observedByRunId: null, + }); + } + const pendingTaskIds = projection.subagents + .filter( + (task) => + task.origin === "app_owned" && + task.runId === parentRun.id && + isTerminalDelegatedTaskStatus(task.status) && + (task.completionDelivery?.state === "pending" || + nextTaskStates.get(task.id)?.state === "pending"), + ) + .map((task) => task.id); + const canReserveFollowUp = + cohort.disposition === "open" && + projection.thread.archivedAt === null && + projection.thread.deletedAt === null && + pendingTaskIds.length > 0; + const nextDelivery = canReserveFollowUp + ? { + generation: cohort.nextGeneration, + messageId: yield* mapDelegatedCompletionError( + idAllocator.allocate.message({ + threadId, + ordinal: + (yield* mapDelegatedCompletionError( + projectionStore.getMessageCount(projection.thread.id), + )) + 1, + }), + ), + taskIds: pendingTaskIds, } - nextTaskStates.set(task.id, { - state: deliveryRun.status === "cancelled" ? "pending" : "delivered", + : null; + if (nextDelivery !== null) { + for (const taskId of pendingTaskIds) { + nextTaskStates.set(taskId, { + state: "claimed", observedByRunId: null, }); } - const pendingTaskIds = projection.subagents - .filter( - (task) => - task.origin === "app_owned" && - task.runId === parentRun.id && - isTerminalDelegatedTaskStatus(task.status) && - (task.completionDelivery?.state === "pending" || - nextTaskStates.get(task.id)?.state === "pending"), - ) - .map((task) => task.id); - const canReserveFollowUp = - cohort.disposition === "open" && - projection.thread.archivedAt === null && - projection.thread.deletedAt === null && - pendingTaskIds.length > 0; - const nextDelivery = canReserveFollowUp - ? { - generation: cohort.nextGeneration, - messageId: yield* mapDelegatedCompletionError( - idAllocator.allocate.message({ - threadId, - ordinal: - (yield* mapDelegatedCompletionError( - projectionStore.getMessageCount(projection.thread.id), - )) + 1, - }), - ), - taskIds: pendingTaskIds, - } - : null; - if (nextDelivery !== null) { - for (const taskId of pendingTaskIds) { - nextTaskStates.set(taskId, { - state: "claimed", - observedByRunId: null, - }); - } + } + const updatedParentRun: OrchestrationV2Run = { + ...parentRun, + delegatedCompletion: { + ...cohort, + nextGeneration: nextDelivery === null ? cohort.nextGeneration : cohort.nextGeneration + 1, + delivery: nextDelivery, + }, + }; + const taskEvents = projection.subagents.flatMap((task) => { + const completionDelivery = nextTaskStates.get(task.id); + if (completionDelivery === undefined) { + return []; } - const updatedParentRun: OrchestrationV2Run = { - ...parentRun, - delegatedCompletion: { - ...cohort, - nextGeneration: nextDelivery === null ? cohort.nextGeneration : cohort.nextGeneration + 1, - delivery: nextDelivery, - }, - }; - const taskEvents = projection.subagents.flatMap((task) => { - const completionDelivery = nextTaskStates.get(task.id); - if (completionDelivery === undefined) { - return []; - } - return [ - { - type: "subagent.updated" as const, - threadId, - ...(task.runId === null ? {} : { runId: task.runId }), - nodeId: task.id, - driver: task.driver, - providerInstanceId: task.providerInstanceId, - occurredAt: now, - payload: { - ...task, - completionDelivery, - updatedAt: now, - }, - }, - ]; - }); - yield* writeSystemEvents([ - ...taskEvents, + return [ { - type: "run.updated", + type: "subagent.updated" as const, threadId, - runId: updatedParentRun.id, - ...(updatedParentRun.rootNodeId === null ? {} : { nodeId: updatedParentRun.rootNodeId }), - providerInstanceId: updatedParentRun.providerInstanceId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + providerInstanceId: task.providerInstanceId, occurredAt: now, - payload: updatedParentRun, + payload: { + ...task, + completionDelivery, + updatedAt: now, + }, }, - ]); - if (nextDelivery !== null) { - yield* offerDelegatedCompletionDelivery(threadId, parentRun.id); - } + ]; }); + yield* writeSystemEvents([ + ...taskEvents, + { + type: "run.updated", + threadId, + runId: updatedParentRun.id, + ...(updatedParentRun.rootNodeId === null ? {} : { nodeId: updatedParentRun.rootNodeId }), + providerInstanceId: updatedParentRun.providerInstanceId, + occurredAt: now, + payload: updatedParentRun, + }, + ]); + if (nextDelivery !== null) { + yield* offerDelegatedCompletionDelivery(threadId, parentRun.id); + } + }); const dispatchNotificationAccepted = Effect.fn("orchestrationV2.notificationAccepted")(function* ( command: Extract, @@ -11534,30 +11502,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; }); - const dispatchWithReceipt = (command: OrchestrationV2ServerCommand) => - Effect.gen(function* () { - const dispatch = threadDispatch.withLock( - commandThreadId(command), - dispatchWithReceiptEffect(command), - ); - if ( - command.type !== "message.dispatch" || - (command.createdBy !== "user" && command.creationSource !== "mcp") - ) - return yield* dispatch; - const parentThreadId = yield* appOwnedSubagentParentThreadId(command.threadId).pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - // Follow-up intake and Stop serialize on the parent before taking the child lock. - return yield* parentThreadId === undefined - ? dispatch - : threadDispatch.withLock(parentThreadId, dispatch); - }); + // dispatchWithReceiptEffect owns the dispatch span; this wrapper selects the thread locks. + const dispatchWithReceipt = Effect.fnUntraced(function* (command: OrchestrationV2ServerCommand) { + const dispatch = threadDispatch.withLock( + commandThreadId(command), + dispatchWithReceiptEffect(command), + ); + if ( + command.type !== "message.dispatch" || + (command.createdBy !== "user" && command.creationSource !== "mcp") + ) + return yield* dispatch; + const parentThreadId = yield* appOwnedSubagentParentThreadId(command.threadId).pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + // Follow-up intake and Stop serialize on the parent before taking the child lock. + return yield* parentThreadId === undefined + ? dispatch + : threadDispatch.withLock(parentThreadId, dispatch); + }); - const handleRunUpdate = (stored: OrchestrationV2StoredEvent) => - Effect.gen(function* () { + const handleRunUpdate = Effect.fn("Orchestrator.handleRunUpdate")( + function* (stored: OrchestrationV2StoredEvent) { if (stored.event.type !== "run.updated") return; const threadId = stored.event.threadId; const run = stored.event.payload; @@ -11593,15 +11561,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio finalizeAppOwnedSubagent(threadId, { syncRunId: run.id }), ); } - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to react to V2 run update", { - threadId: stored.event.threadId, - sequence: stored.sequence, - cause, - }), + }, + (effect, stored) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to react to V2 run update", { + threadId: stored.event.threadId, + sequence: stored.sequence, + cause, + }), + ), ), - ); + ); // Historical terminal events are already represented by the projections // below. Replaying the full event table on every server start delays live @@ -11727,8 +11698,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); }); - const delegatedTaskResultPending = (childThreadId: ThreadId, taskId?: NodeId) => - Effect.gen(function* () { + const delegatedTaskResultPending = Effect.fn("Orchestrator.delegatedTaskResultPending")( + function* (childThreadId: ThreadId, taskId?: NodeId) { const child = yield* projectionStore.getThreadRecords( childThreadId, ["runs", "messages", "subagents", "providerThreads", "providerTurns", "attempts"], @@ -11744,28 +11715,34 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (progress.state !== "result_available") return true; if (progress.resultRun === undefined) return false; return yield* childAwaitsRestartContinuation(child.runs, progress.resultRun); - }).pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + }, + (effect, childThreadId, _taskId?) => + effect.pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + ), ), - ); + ); - const recoverDelegatedTask = (threadId: ThreadId, sourceRunId: RunId) => - Effect.gen(function* () { + const recoverDelegatedTask = Effect.fn("Orchestrator.recoverDelegatedTask")( + function* (threadId: ThreadId, sourceRunId: RunId) { const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); if (parentThreadId === undefined) return; yield* threadDispatch.withLock( parentThreadId, finalizeAppOwnedSubagent(threadId, { settledContinuationOf: sourceRunId }), ); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to recover delegated task after restart", { - childThreadId: threadId, - cause, - }), + }, + (effect, threadId, _sourceRunId) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to recover delegated task after restart", { + childThreadId: threadId, + cause, + }), + ), ), - ); + ); const shellProjectionError = (cause: unknown) => new OrchestratorProjectionError({ threadId: ThreadId.make("thread:shell"), cause }); diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts index 58609d18ce2f..4d63447fc045 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts @@ -136,26 +136,25 @@ export const layerFromProviderInstanceRegistry: Layer.Layer< instances.listInstances.pipe( Effect.map((available) => available.map((instance) => instance.instanceId)), ), - getMetadata: (instanceId) => - Effect.gen(function* () { - const instance = yield* instances.getInstance(instanceId); - if (instance === undefined) { - return yield* new ProviderAdapterRegistryLookupError({ instanceId }); - } - const capabilities = yield* instance.orchestrationAdapter - .getCapabilities() - .pipe( - Effect.mapError( - (cause) => new ProviderAdapterRegistryMetadataError({ instanceId, cause }), - ), - ); - return { - driver: instance.driverKind, - continuationKey: instance.continuationIdentity.continuationKey, - enabled: instance.enabled, - capabilities, - }; - }), + getMetadata: Effect.fn("ProviderAdapterRegistry.getMetadata")(function* (instanceId) { + const instance = yield* instances.getInstance(instanceId); + if (instance === undefined) { + return yield* new ProviderAdapterRegistryLookupError({ instanceId }); + } + const capabilities = yield* instance.orchestrationAdapter + .getCapabilities() + .pipe( + Effect.mapError( + (cause) => new ProviderAdapterRegistryMetadataError({ instanceId, cause }), + ), + ); + return { + driver: instance.driverKind, + continuationKey: instance.continuationIdentity.continuationKey, + enabled: instance.enabled, + capabilities, + }; + }), }); }), ); @@ -167,14 +166,13 @@ function makeRegistry( adapters: ReadonlyArray, ): ProviderAdapterRegistryV2Shape { return { - get: (instanceId) => - Effect.gen(function* () { - const adapter = adapters.find((candidate) => candidate.instanceId === instanceId); - if (!adapter) { - return yield* new ProviderAdapterRegistryLookupError({ instanceId }); - } - return adapter; - }), + get: Effect.fn("ProviderAdapterRegistry.get")(function* (instanceId) { + const adapter = adapters.find((candidate) => candidate.instanceId === instanceId); + if (!adapter) { + return yield* new ProviderAdapterRegistryLookupError({ instanceId }); + } + return adapter; + }), list: () => Effect.succeed(adapters.map((adapter) => adapter.instanceId)), }; } @@ -318,20 +316,20 @@ const buildAdaptersFromConfigMap = Effect.fn("ProviderAdapterRegistry.buildAdapt }, ); -export function makeRegistryFromConfigMap(input: { +export const makeRegistryFromConfigMap = Effect.fn( + "ProviderAdapterRegistry.makeRegistryFromConfigMap", +)(function* (input: { readonly drivers: ReadonlyArray>; readonly configMap: ProviderInstanceConfigMap; -}): Effect.Effect< +}): Effect.fn.Return< ProviderAdapterRegistryV2Shape, ProviderAdapterRegistryBuildError, R | Scope.Scope > { - return Effect.gen(function* () { - const parentScope = yield* Effect.scope; - const entries = yield* buildAdaptersFromConfigMap({ ...input, parentScope }); - return makeRegistry(Array.from(entries.values()).map((entry) => entry.adapter)); - }); -} + const parentScope = yield* Effect.scope; + const entries = yield* buildAdaptersFromConfigMap({ ...input, parentScope }); + return makeRegistry(Array.from(entries.values()).map((entry) => entry.adapter)); +}); export function layerFromDrivers(input: { readonly drivers: ReadonlyArray>; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 32de3b9d6a4f..a3c41fed25f5 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -696,62 +696,61 @@ export const make = Effect.gen(function* () { }, ); - const reconcile = (trigger: "startup" | "shutdown") => - Effect.gen(function* () { - const continueAfterRestart = yield* settings.getSettings.pipe( - Effect.orElseSucceed(() => null), + const reconcile = Effect.fn("ProviderRuntimeRecoveryService.reconcile")(function* ( + trigger: "startup" | "shutdown", + ) { + const continueAfterRestart = yield* settings.getSettings.pipe(Effect.orElseSucceed(() => null)); + const threadIds = yield* projections + .getRecoveryThreadIds("runtime") + .pipe( + Effect.mapError( + (cause) => new ProviderRuntimeRecoveryError({ operation: "read-projections", cause }), + ), ); - const threadIds = yield* projections - .getRecoveryThreadIds("runtime") - .pipe( - Effect.mapError( - (cause) => new ProviderRuntimeRecoveryError({ operation: "read-projections", cause }), - ), - ); - let terminalizedRuns = 0; - let stoppedSessions = 0; - let closedRequests = 0; - let retiredEffects = 0; - const stoppedThisPass = new Map(); - for (const threadId of threadIds) { - const projection = yield* projections.getRuntimeRecoveryProjection(threadId).pipe( - Effect.mapError( - (cause) => - new ProviderRuntimeRecoveryError({ - operation: "read-projections", - threadId, - cause, - }), - ), - ); - const enabled = - continueAfterRestart !== null && - resolveProjectSettings(continueAfterRestart, projection.thread.projectId).settings - .continueThreadsAfterServerUpdate; - const result = yield* reconcileProjection(projection, trigger, enabled, stoppedThisPass); - for (const session of projection.providerSessions) { - if (session.status !== "stopped" && session.status !== "error") { - stoppedThisPass.set(session.id, session); - } - } - terminalizedRuns += result.terminalizedRuns; - stoppedSessions += result.stoppedSessions; - closedRequests += result.closedRequests; - retiredEffects += result.retiredEffects; - } - const outboxReconciliation = yield* outbox.reconcileAfterProcessLoss.pipe( + let terminalizedRuns = 0; + let stoppedSessions = 0; + let closedRequests = 0; + let retiredEffects = 0; + const stoppedThisPass = new Map(); + for (const threadId of threadIds) { + const projection = yield* projections.getRuntimeRecoveryProjection(threadId).pipe( Effect.mapError( - (cause) => new ProviderRuntimeRecoveryError({ operation: "drain-outbox", cause }), + (cause) => + new ProviderRuntimeRecoveryError({ + operation: "read-projections", + threadId, + cause, + }), ), ); - return { - terminalizedRuns, - stoppedSessions, - closedRequests, - retiredEffects: retiredEffects + outboxReconciliation.cancelled, - requeuedEffects: outboxReconciliation.requeued, - } satisfies ProviderRuntimeReconciliationSummary; - }); + const enabled = + continueAfterRestart !== null && + resolveProjectSettings(continueAfterRestart, projection.thread.projectId).settings + .continueThreadsAfterServerUpdate; + const result = yield* reconcileProjection(projection, trigger, enabled, stoppedThisPass); + for (const session of projection.providerSessions) { + if (session.status !== "stopped" && session.status !== "error") { + stoppedThisPass.set(session.id, session); + } + } + terminalizedRuns += result.terminalizedRuns; + stoppedSessions += result.stoppedSessions; + closedRequests += result.closedRequests; + retiredEffects += result.retiredEffects; + } + const outboxReconciliation = yield* outbox.reconcileAfterProcessLoss.pipe( + Effect.mapError( + (cause) => new ProviderRuntimeRecoveryError({ operation: "drain-outbox", cause }), + ), + ); + return { + terminalizedRuns, + stoppedSessions, + closedRequests, + retiredEffects: retiredEffects + outboxReconciliation.cancelled, + requeuedEffects: outboxReconciliation.requeued, + } satisfies ProviderRuntimeReconciliationSummary; + }); const prepareForShutdown = Effect.gen(function* () { const enabled = yield* settings.getSettings.pipe(Effect.orElseSucceed(() => null)); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index edcaecfa8848..3164186c9fc6 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -525,220 +525,227 @@ export const layerWithOptions = ( ), ); - const failSubscribers = (entry: LiveSessionEntry, detail: string) => - Effect.gen(function* () { - const error = new ProviderAdapter.ProviderAdapterEventStreamError({ - driver: entry.runtime.driver, - providerSessionId: entry.runtime.providerSessionId, - cause: detail, - }); - const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); - yield* Effect.forEach( - subscribers.values(), - (queue) => - Queue.offer(queue, { - type: "failure", - cause: Cause.fail(error), - }), - { discard: true }, - ); + const failSubscribers = Effect.fn("ProviderSessionManager.failSubscribers")(function* ( + entry: LiveSessionEntry, + detail: string, + ) { + const error = new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: entry.runtime.driver, + providerSessionId: entry.runtime.providerSessionId, + cause: detail, }); + const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); + yield* Effect.forEach( + subscribers.values(), + (queue) => + Queue.offer(queue, { + type: "failure", + cause: Cause.fail(error), + }), + { discard: true }, + ); + }); // Server shutdown interrupts subscribers rather than ending them: a // cleanly ended stream without a terminal fails its run, while an // interrupted one leaves run settlement to restart recovery. - const interruptSubscribers = (entry: LiveSessionEntry) => - Effect.gen(function* () { + const interruptSubscribers = Effect.fn("ProviderSessionManager.interruptSubscribers")( + function* (entry: LiveSessionEntry) { const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); yield* Effect.forEach( subscribers.values(), (queue) => Queue.clear(queue).pipe(Effect.andThen(Queue.interrupt(queue))), { discard: true }, ); - }); + }, + ); // Server shutdown intentionally clears them; a provider-announced Stop // must let consumers drain them before the stream completes. - const endSubscribers = (entry: LiveSessionEntry) => - Effect.gen(function* () { - const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); - yield* Effect.forEach(subscribers.values(), (queue) => Queue.end(queue), { - discard: true, - }); + const endSubscribers = Effect.fn("ProviderSessionManager.endSubscribers")(function* ( + entry: LiveSessionEntry, + ) { + const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); + yield* Effect.forEach(subscribers.values(), (queue) => Queue.end(queue), { + discard: true, }); + }); const cancelIdleFiber = (fiber: Fiber.Fiber | null) => fiber === null ? Effect.void : Fiber.interrupt(fiber).pipe(Effect.ignore); - const writeProviderSessionEvents = (input: { + const writeProviderSessionEvents = Effect.fn( + "ProviderSessionManager.writeProviderSessionEvents", + )(function* (input: { readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; readonly threadIds: Iterable; readonly type: "provider-session.attached" | "provider-session.updated"; readonly payload: OrchestrationV2ProviderSession; - }) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const events = yield* Effect.forEach(input.threadIds, (threadId) => - Effect.gen(function* () { - return { - id: yield* idAllocator.allocate.event({ - threadId, - providerSessionId: input.runtime.providerSessionId, - }), - type: input.type, + }) { + const now = yield* DateTime.now; + const events = yield* Effect.forEach(input.threadIds, (threadId) => + Effect.gen(function* () { + return { + id: yield* idAllocator.allocate.event({ threadId, - driver: input.runtime.driver, - providerInstanceId: input.runtime.instanceId, - occurredAt: now, - payload: input.payload, - } satisfies OrchestrationV2DomainEvent; - }), - ); - if (events.length > 0) { - yield* eventSink.write({ events }); - } - }); + providerSessionId: input.runtime.providerSessionId, + }), + type: input.type, + threadId, + driver: input.runtime.driver, + providerInstanceId: input.runtime.instanceId, + occurredAt: now, + payload: input.payload, + } satisfies OrchestrationV2DomainEvent; + }), + ); + if (events.length > 0) { + yield* eventSink.write({ events }); + } + }); - const writeReleasedSessionEvents = (input: { + const writeReleasedSessionEvents = Effect.fn( + "ProviderSessionManager.writeReleasedSessionEvents", + )(function* (input: { readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; readonly detail?: string; - }) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const payload: OrchestrationV2ProviderSession = { - ...input.entry.runtime.providerSession, - status: releaseStatusFor(input.reason), - updatedAt: now, - lastError: - input.reason === "runtime_error" - ? (input.detail ?? "Provider runtime failed.") - : null, - }; - yield* writeProviderSessionEvents({ - runtime: input.entry.runtime, - threadIds: input.entry.attachedThreadIds, - type: "provider-session.updated", - payload, - }); + }) { + const now = yield* DateTime.now; + const payload: OrchestrationV2ProviderSession = { + ...input.entry.runtime.providerSession, + status: releaseStatusFor(input.reason), + updatedAt: now, + lastError: + input.reason === "runtime_error" ? (input.detail ?? "Provider runtime failed.") : null, + }; + yield* writeProviderSessionEvents({ + runtime: input.entry.runtime, + threadIds: input.entry.attachedThreadIds, + type: "provider-session.updated", + payload, }); + }); - const writeReleasedRuntimeRequestEvents = (input: { + const writeReleasedRuntimeRequestEvents = Effect.fn( + "ProviderSessionManager.writeReleasedRuntimeRequestEvents", + )(function* (input: { readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; readonly releasedAt: DateTime.Utc; - }) => - Effect.gen(function* () { - const providerSessionId = input.entry.runtime.providerSessionId; - const now = yield* DateTime.now; - const status = releasedRuntimeRequestStatusFor(input.reason); - const reason = - input.reason === "runtime_error" - ? "Provider session failed before this runtime request was resolved." - : "Provider session was closed before this runtime request was resolved."; - - const events: Array = []; - for (const threadId of input.entry.attachedThreadIds) { - const projection = yield* projectionStore.getThreadRecords( + }) { + const providerSessionId = input.entry.runtime.providerSessionId; + const now = yield* DateTime.now; + const status = releasedRuntimeRequestStatusFor(input.reason); + const reason = + input.reason === "runtime_error" + ? "Provider session failed before this runtime request was resolved." + : "Provider session was closed before this runtime request was resolved."; + + const events: Array = []; + for (const threadId of input.entry.attachedThreadIds) { + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runtimeRequests", "nodes", "turnItems"], + { turnItemTypes: ["approval_request", "user_input_request"] }, + ); + const releasedRequests = projection.runtimeRequests.filter( + (request) => + request.status === "pending" && + request.responseCapability.type === "live" && + request.responseCapability.providerSessionId === providerSessionId && + DateTime.isLessThanOrEqualTo(request.createdAt, input.releasedAt), + ); + + for (const request of releasedRequests) { + events.push({ + id: yield* idAllocator.allocate.event({ + threadId, + providerSessionId, + }), + type: "runtime-request.updated", threadId, - ["runtimeRequests", "nodes", "turnItems"], - { turnItemTypes: ["approval_request", "user_input_request"] }, - ); - const releasedRequests = projection.runtimeRequests.filter( - (request) => - request.status === "pending" && - request.responseCapability.type === "live" && - request.responseCapability.providerSessionId === providerSessionId && - DateTime.isLessThanOrEqualTo(request.createdAt, input.releasedAt), - ); + nodeId: request.nodeId, + driver: input.entry.runtime.driver, + occurredAt: now, + payload: { + ...request, + status, + responseCapability: { + type: "not_resumable", + reason, + }, + resolvedAt: now, + }, + }); - for (const request of releasedRequests) { + const requestNode = projection.nodes.find((node) => node.id === request.nodeId); + if (requestNode !== undefined) { events.push({ id: yield* idAllocator.allocate.event({ threadId, providerSessionId, }), - type: "runtime-request.updated", + type: "node.updated", threadId, - nodeId: request.nodeId, + ...(requestNode.runId === null ? {} : { runId: requestNode.runId }), + nodeId: requestNode.id, driver: input.entry.runtime.driver, occurredAt: now, payload: { - ...request, - status, - responseCapability: { - type: "not_resumable", - reason, - }, - resolvedAt: now, + ...requestNode, + status: input.reason === "runtime_error" ? "failed" : "cancelled", + completedAt: now, }, }); + } - const requestNode = projection.nodes.find((node) => node.id === request.nodeId); - if (requestNode !== undefined) { - events.push({ - id: yield* idAllocator.allocate.event({ - threadId, - providerSessionId, - }), - type: "node.updated", - threadId, - ...(requestNode.runId === null ? {} : { runId: requestNode.runId }), - nodeId: requestNode.id, - driver: input.entry.runtime.driver, - occurredAt: now, - payload: { - ...requestNode, - status: input.reason === "runtime_error" ? "failed" : "cancelled", - completedAt: now, - }, - }); - } - - const turnItem = projection.turnItems.find( - (item) => - (item.type === "approval_request" || item.type === "user_input_request") && - item.requestId === request.id, - ); - if (turnItem !== undefined) { - events.push({ - id: yield* idAllocator.allocate.event({ - threadId, - providerSessionId, - }), - type: "turn-item.updated", + const turnItem = projection.turnItems.find( + (item) => + (item.type === "approval_request" || item.type === "user_input_request") && + item.requestId === request.id, + ); + if (turnItem !== undefined) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId, - ...(turnItem.runId === null ? {} : { runId: turnItem.runId }), - ...(turnItem.nodeId === null ? {} : { nodeId: turnItem.nodeId }), - driver: input.entry.runtime.driver, - occurredAt: now, - payload: { - ...turnItem, - status: input.reason === "runtime_error" ? "failed" : "cancelled", - completedAt: now, - updatedAt: now, - }, - }); - } + providerSessionId, + }), + type: "turn-item.updated", + threadId, + ...(turnItem.runId === null ? {} : { runId: turnItem.runId }), + ...(turnItem.nodeId === null ? {} : { nodeId: turnItem.nodeId }), + driver: input.entry.runtime.driver, + occurredAt: now, + payload: { + ...turnItem, + status: input.reason === "runtime_error" ? "failed" : "cancelled", + completedAt: now, + updatedAt: now, + }, + }); } } + } - if (events.length > 0) { - yield* eventSink.write({ events }); - } - }); + if (events.length > 0) { + yield* eventSink.write({ events }); + } + }); // 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. - const readReleasedBackgroundTasks = (input: { - readonly entry: LiveSessionEntry; - readonly reason: ProviderSessionReleaseReason; - readonly detachedThreadId?: ThreadId; - }) => - Effect.gen(function* () { + const readReleasedBackgroundTasks = Effect.fn( + "ProviderSessionManager.readReleasedBackgroundTasks", + )( + function* (input: { + readonly entry: LiveSessionEntry; + readonly reason: ProviderSessionReleaseReason; + readonly detachedThreadId?: ThreadId; + }) { if ( input.reason === "server_shutdown" || input.entry.runtime.backgroundTasksEndWithProcess !== true @@ -767,14 +774,17 @@ export const layerWithOptions = ( } } return released; - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("orchestration-v2.provider-session-release-roster-read-failed", { - providerSessionId: input.entry.runtime.providerSessionId, - cause, - }).pipe(Effect.as([])), + }, + (effect, input) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.provider-session-release-roster-read-failed", { + providerSessionId: input.entry.runtime.providerSessionId, + cause, + }).pipe(Effect.as([])), + ), ), - ); + ); type ReleasedBackgroundTasks = Effect.Success< ReturnType >[number]; @@ -907,11 +917,11 @@ export const layerWithOptions = ( // A request the event pump is persisting for this runtime lands before // the cutoff, and once the entry is gone the pump persists no more for // it. - const removeLiveEntry = (input: { - readonly providerSessionId: ProviderSessionId; - readonly onlyIfIdleGeneration?: number; - }): Effect.Effect, DateTime.Utc]> => - Effect.gen(function* () { + const removeLiveEntry = Effect.fn("ProviderSessionManager.removeLiveEntry")( + function* (input: { + readonly providerSessionId: ProviderSessionId; + readonly onlyIfIdleGeneration?: number; + }): Effect.fn.Return, DateTime.Utc]> { const key = sessionKey(input.providerSessionId); const candidate = (yield* Ref.get(sessions)).get(key); if (candidate === undefined) { @@ -942,44 +952,44 @@ export const layerWithOptions = ( outcome === "removed" ? Option.some(candidate) : Option.none(), releasedAt, ] as const; - }); + }, + ); // Scope close can wedge on a misbehaving adapter finalizer (e.g. a // provider process that never yields its message stream). - const closeScopeWithin = ( + const closeScopeWithin = Effect.fn("ProviderSessionManager.closeScopeWithin")(function* ( scope: Scope.Closeable, annotations: { readonly providerSessionId?: ProviderSessionId; readonly reason: string }, - ) => - Effect.gen(function* () { - const closeFiber = yield* Scope.close(scope, Exit.void).pipe( - Effect.exit, - Effect.forkDetach({ startImmediately: true }), - ); - const closeExit = yield* Fiber.join(closeFiber).pipe( - Effect.timeoutOption(RELEASE_SCOPE_CLOSE_TIMEOUT_MS), + ) { + const closeFiber = yield* Scope.close(scope, Exit.void).pipe( + Effect.exit, + Effect.forkDetach({ startImmediately: true }), + ); + const closeExit = yield* Fiber.join(closeFiber).pipe( + Effect.timeoutOption(RELEASE_SCOPE_CLOSE_TIMEOUT_MS), + ); + if (Option.isNone(closeExit)) { + yield* Effect.logWarning("orchestration-v2.provider-session-scope-close-timeout", { + ...annotations, + timeoutMs: RELEASE_SCOPE_CLOSE_TIMEOUT_MS, + }); + yield* Fiber.join(closeFiber).pipe( + Effect.flatMap((exit) => + Exit.isFailure(exit) + ? Effect.logWarning("orchestration-v2.provider-session-scope-close-failed", { + ...annotations, + cause: exit.cause, + }) + : Effect.logInfo( + "orchestration-v2.provider-session-scope-close-completed-late", + annotations, + ), + ), + Effect.forkDetach, ); - if (Option.isNone(closeExit)) { - yield* Effect.logWarning("orchestration-v2.provider-session-scope-close-timeout", { - ...annotations, - timeoutMs: RELEASE_SCOPE_CLOSE_TIMEOUT_MS, - }); - yield* Fiber.join(closeFiber).pipe( - Effect.flatMap((exit) => - Exit.isFailure(exit) - ? Effect.logWarning("orchestration-v2.provider-session-scope-close-failed", { - ...annotations, - cause: exit.cause, - }) - : Effect.logInfo( - "orchestration-v2.provider-session-scope-close-completed-late", - annotations, - ), - ), - Effect.forkDetach, - ); - } - return closeExit; - }); + } + return closeExit; + }); const releaseEntry = (input: { readonly providerSessionId: ProviderSessionId; @@ -1096,11 +1106,11 @@ export const layerWithOptions = ( ), ); - const releaseIfStillIdle = (input: { - readonly providerSessionId: ProviderSessionId; - readonly generation: number; - }): Effect.Effect => - Effect.gen(function* () { + const releaseIfStillIdle = Effect.fn("ProviderSessionManager.releaseIfStillIdle")( + function* (input: { + readonly providerSessionId: ProviderSessionId; + readonly generation: number; + }): Effect.fn.Return { const current = yield* Ref.get(sessions); const key = sessionKey(input.providerSessionId); const entry = current.get(key); @@ -1173,7 +1183,8 @@ export const layerWithOptions = ( }), ), ); - }); + }, + ); const withActivityError = ( providerSessionId: ProviderSessionId, @@ -1192,42 +1203,42 @@ export const layerWithOptions = ( // Provider activity restarts the pin budget, so background work that keeps // reporting progress (a long Claude workflow) is never cut off; only work - // that went silent for maxIdlePinMs is released. - const scheduleIdleReleaseInternal = ( + // that went silent for maxIdlePinMs is released. This runs for every + // provider event, so keep it untraced. + const scheduleIdleReleaseInternal = Effect.fnUntraced(function* ( providerSessionId: ProviderSessionId, options?: { readonly providerActivity?: boolean }, - ) => - Effect.gen(function* () { - const key = sessionKey(providerSessionId); - const current = yield* Ref.get(sessions); - const entry = current.get(key); - if (entry === undefined || entry.busyTurns.size > 0) { - return; - } + ) { + const key = sessionKey(providerSessionId); + const current = yield* Ref.get(sessions); + const entry = current.get(key); + if (entry === undefined || entry.busyTurns.size > 0) { + return; + } - yield* cancelIdleFiber(entry.idleFiber); - const generation = entry.idleGeneration + 1; - const idleFiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( - Effect.andThen(releaseIfStillIdle({ providerSessionId, generation })), - Effect.forkIn(layerScope), - ); - const lastActivityAtMs = yield* Clock.currentTimeMillis; - yield* Ref.update(sessions, (latest) => { - const latestEntry = latest.get(key); - if (latestEntry === undefined || latestEntry.busyTurns.size > 0) { - return latest; - } - const updated = new Map(latest); - updated.set(key, { - ...latestEntry, - idleGeneration: generation, - idleFiber, - lastActivityAtMs, - ...(options?.providerActivity === true ? { pinnedSinceMs: null } : {}), - }); - return updated; + yield* cancelIdleFiber(entry.idleFiber); + const generation = entry.idleGeneration + 1; + const idleFiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( + Effect.andThen(releaseIfStillIdle({ providerSessionId, generation })), + Effect.forkIn(layerScope), + ); + const lastActivityAtMs = yield* Clock.currentTimeMillis; + yield* Ref.update(sessions, (latest) => { + const latestEntry = latest.get(key); + if (latestEntry === undefined || latestEntry.busyTurns.size > 0) { + return latest; + } + const updated = new Map(latest); + updated.set(key, { + ...latestEntry, + idleGeneration: generation, + idleFiber, + lastActivityAtMs, + ...(options?.providerActivity === true ? { pinnedSinceMs: null } : {}), }); + return updated; }); + }); const scheduleIdleRelease = (providerSessionId: ProviderSessionId) => withActivityError(providerSessionId, scheduleIdleReleaseInternal(providerSessionId)); @@ -1518,12 +1529,12 @@ export const layerWithOptions = ( * cannot land between a resume that found the thread loaded and the turn * that relies on it. */ - const holdThreadLoaded = (input: { - readonly providerSessionId: ProviderSessionId; - readonly threadId: ThreadId; - readonly providerThread: OrchestrationV2ProviderThread; - }) => - Effect.gen(function* () { + const holdThreadLoaded = Effect.fn("ProviderSessionManager.holdThreadLoaded")( + function* (input: { + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + }) { const entry = (yield* Ref.get(sessions)).get(sessionKey(input.providerSessionId)); if ( entry === undefined || @@ -1543,13 +1554,11 @@ export const layerWithOptions = ( }), ); yield* cancelIdleFiber(previous?.fiber ?? null); - }); + }, + ); - const scheduleThreadUnload = ( - providerSessionId: ProviderSessionId, - providerThreadId: ProviderThreadId, - ) => - Effect.gen(function* () { + const scheduleThreadUnload = Effect.fn("ProviderSessionManager.scheduleThreadUnload")( + function* (providerSessionId: ProviderSessionId, providerThreadId: ProviderThreadId) { const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); if (entry === undefined || hasBusyTurn(entry, providerThreadId)) return; const tracked = Array.from(entry.idleThreadUnloads).find( @@ -1570,14 +1579,15 @@ export const layerWithOptions = ( yield* cancelIdleFiber( previous?.generation === pending.generation ? previous.fiber : fiber, ); - }); + }, + ); - const unloadIdleThread = (input: { - readonly providerSessionId: ProviderSessionId; - readonly threadId: ThreadId; - readonly generation: number; - }): Effect.Effect => - Effect.gen(function* () { + const unloadIdleThread = Effect.fn("ProviderSessionManager.unloadIdleThread")( + function* (input: { + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; + readonly generation: number; + }): Effect.fn.Return { const outcome = yield* threadAttachment.withLock( threadAttachmentKey(input), Effect.gen(function* () { @@ -1647,7 +1657,8 @@ export const layerWithOptions = ( yield* Effect.sleep(Duration.millis(idleTimeoutMs)); return yield* unloadIdleThread(input); } - }); + }, + ); const observeActivity = ( providerSessionId: ProviderSessionId, @@ -1662,12 +1673,12 @@ export const layerWithOptions = ( ), ); - const makeEventSubscription = ( - subscribers: Ref.Ref< - ReadonlyMap> - >, - ): Effect.Effect => - Effect.gen(function* () { + const makeEventSubscription = Effect.fn("ProviderSessionManager.makeEventSubscription")( + function* ( + subscribers: Ref.Ref< + ReadonlyMap> + >, + ): Effect.fn.Return { const queue = yield* Queue.unbounded(); const subscriberId = yield* Ref.getAndUpdate(nextSubscriberId, (value) => value + 1); yield* Ref.update(subscribers, (current) => { @@ -1698,7 +1709,8 @@ export const layerWithOptions = ( Stream.ensuring(close), ); return { events, close } satisfies ProviderAdapter.ProviderAdapterV2EventSubscription; - }); + }, + ); const decorateRuntime = ( runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, @@ -1880,14 +1892,16 @@ export const layerWithOptions = ( }; }; - const persistProviderSessionUpdate = ( - entry: LiveSessionEntry, - event: Extract< - ProviderAdapter.ProviderAdapterV2Event, - { readonly type: "provider_session.updated" } - >, - ) => - Effect.gen(function* () { + const persistProviderSessionUpdate = Effect.fn( + "ProviderSessionManager.persistProviderSessionUpdate", + )( + function* ( + entry: LiveSessionEntry, + event: Extract< + ProviderAdapter.ProviderAdapterV2Event, + { readonly type: "provider_session.updated" } + >, + ) { const current = (yield* Ref.get(sessions)).get( sessionKey(entry.runtime.providerSessionId), ); @@ -1900,14 +1914,17 @@ export const layerWithOptions = ( type: "provider-session.updated", payload: event.providerSession, }); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("orchestration-v2.driver-session.status-persist-failed", { - providerSessionId: entry.runtime.providerSessionId, - cause, - }), + }, + (effect, entry, _event) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.status-persist-failed", { + providerSessionId: entry.runtime.providerSessionId, + cause, + }), + ), ), - ); + ); const startEventPump = (entry: LiveSessionEntry) => { let stoppedByProvider = false; @@ -2248,23 +2265,26 @@ export const layerWithOptions = ( Ref.get(sessions).pipe( Effect.map((current) => current.has(sessionKey(providerSessionId))), ), - get: (providerSessionId) => - Effect.gen(function* () { + get: Effect.fn("ProviderSessionManager.get")( + function* (providerSessionId) { const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); if (entry === undefined) { return Option.none(); } yield* touchActivity(providerSessionId); return Option.some(entry.exposedRuntime); - }).pipe( - Effect.mapError( - (cause) => - new ProviderSessionLookupError({ - providerSessionId, - cause, - }), + }, + (effect, providerSessionId) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderSessionLookupError({ + providerSessionId, + cause, + }), + ), ), - ), + ), close: (providerSessionId) => releaseEntry({ providerSessionId, reason: "manual_shutdown" }).pipe( Effect.mapError( @@ -2275,8 +2295,8 @@ export const layerWithOptions = ( }), ), ), - closeInstance: (instanceId) => - Effect.gen(function* () { + closeInstance: Effect.fn("ProviderSessionManager.closeInstance")( + function* (instanceId) { const active = [...(yield* Ref.get(sessions)).values()].filter( (entry) => entry.runtime.instanceId === instanceId, ); @@ -2294,20 +2314,23 @@ export const layerWithOptions = ( if (failure !== undefined && Exit.isFailure(failure)) { return yield* Effect.failCause(failure.cause); } - }).pipe( - Effect.mapError( - (cause) => - new ProviderSessionCloseError({ - providerSessionId: ProviderSessionId.make( - `provider-session:provider-instance:${instanceId}`, - ), - cause, - }), + }, + (effect, instanceId) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderSessionCloseError({ + providerSessionId: ProviderSessionId.make( + `provider-session:provider-instance:${instanceId}`, + ), + cause, + }), + ), ), - ), + ), release: releaseEntry, - detach: (input) => - Effect.gen(function* () { + detach: Effect.fn("ProviderSessionManager.detach")( + function* (input) { const key = sessionKey(input.providerSessionId); const currentEntry = (yield* Ref.get(sessions)).get(key); let detachedProviderThreads: ReadonlyArray = []; @@ -2461,17 +2484,20 @@ export const layerWithOptions = ( ); } yield* scheduleIdleRelease(input.providerSessionId); - }).pipe( - Effect.catchCause((cause) => - Effect.fail( - new ProviderSessionReleaseError({ - providerSessionId: input.providerSessionId, - reason: "manual_shutdown", - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.catchCause((cause) => + Effect.fail( + new ProviderSessionReleaseError({ + providerSessionId: input.providerSessionId, + reason: "manual_shutdown", + cause, + }), + ), ), ), - ), + ), } satisfies ProviderSessionManagerV2Shape); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.ts index b27bb39bcdf0..ed343e13e7f5 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.ts @@ -64,8 +64,8 @@ export const layer: Layer.Layer< Effect.gen(function* () { const adapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; return ProviderSwitchServiceV2.of({ - plan: ({ projection, targetModelSelection }) => - Effect.gen(function* () { + plan: Effect.fn("ProviderSwitchService.plan")( + function* ({ projection, targetModelSelection }) { const current = projection.thread.modelSelection; const instanceChanged = current.instanceId !== targetModelSelection.instanceId; const modelChanged = current.model !== targetModelSelection.model; @@ -198,16 +198,19 @@ export const layer: Layer.Layer< releaseProviderSessionIds, transition, }; - }).pipe( - Effect.mapError( - (cause) => - new ProviderSwitchPlanError({ - threadId: projection.thread.id, - targetProviderInstanceId: targetModelSelection.instanceId, - cause, - }), + }, + (effect, { projection, targetModelSelection }) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderSwitchPlanError({ + threadId: projection.thread.id, + targetProviderInstanceId: targetModelSelection.instanceId, + cause, + }), + ), ), - ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index de03594916a8..56bbae37dd91 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -91,7 +91,7 @@ export const layer: Layer.Layer< const projections = yield* ProjectionStore.ProjectionStoreV2; const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; - const load = (input: { + const load = Effect.fn("ProviderTurnControlService.load")(function* (input: { readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly replacementProviderSessionId?: ProviderSessionId; @@ -99,46 +99,68 @@ export const layer: Layer.Layer< readonly providerTurnId: ProviderTurnId; readonly operation: "interrupt" | "restart" | "steer"; readonly messageId?: MessageId; - }) => - Effect.gen(function* () { - const context = yield* projections.getProviderControlContext(input.threadId, input); - const { providerThread, providerTurn } = context; - const targetsRecordedSession = - providerThread?.providerSessionId === input.providerSessionId; - const targetsCommittedReplacement = - input.operation === "restart" && - input.replacementProviderSessionId !== undefined && - providerThread?.providerSessionId === input.replacementProviderSessionId; - if ( - providerThread === undefined || - providerTurn === undefined || - (!targetsRecordedSession && !targetsCommittedReplacement) || - providerTurn.providerThreadId !== providerThread.id - ) { + }) { + const context = yield* projections.getProviderControlContext(input.threadId, input); + const { providerThread, providerTurn } = context; + const targetsRecordedSession = providerThread?.providerSessionId === input.providerSessionId; + const targetsCommittedReplacement = + input.operation === "restart" && + input.replacementProviderSessionId !== undefined && + providerThread?.providerSessionId === input.replacementProviderSessionId; + if ( + providerThread === undefined || + providerTurn === undefined || + (!targetsRecordedSession && !targetsCommittedReplacement) || + providerTurn.providerThreadId !== providerThread.id + ) { + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: input.operation, + providerTurnId: input.providerTurnId, + cause: "The recorded provider execution target is no longer valid.", + }); + } + // A restart-session command commits the replacement binding before its + // process-bound effect runs. The old live runtime must still receive + // the interrupt, but only when the projection matches the exact + // replacement captured by that same durable effect. + const interruptProviderThread = targetsRecordedSession + ? providerThread + : { ...providerThread, providerSessionId: input.providerSessionId }; + if (providerTurn.status !== "running") { + if (input.operation === "steer") { return yield* new ProviderTurnControlError({ threadId: input.threadId, - operation: input.operation, + operation: "steer", providerTurnId: input.providerTurnId, - cause: "The recorded provider execution target is no longer valid.", + turnCompleted: providerTurn.status === "completed", + cause: "The provider turn ended before the steering message was delivered.", }); } - // A restart-session command commits the replacement binding before its - // process-bound effect runs. The old live runtime must still receive - // the interrupt, but only when the projection matches the exact - // replacement captured by that same durable effect. - const interruptProviderThread = targetsRecordedSession - ? providerThread - : { ...providerThread, providerSessionId: input.providerSessionId }; - if (providerTurn.status !== "running") { - if (input.operation === "steer") { - return yield* new ProviderTurnControlError({ + return { + context, + providerThread: interruptProviderThread, + providerTurn, + session: Option.none(), + }; + } + const session = yield* sessions.get(input.providerSessionId); + if (Option.isNone(session)) { + // Interrupt/restart against a already-released session must not fail + // the durable effect (and retry 5x). The turn may still look running + // in projection until recovery/finalization; there is no live adapter + // to interrupt. + if (input.operation === "interrupt" || input.operation === "restart") { + yield* Effect.logWarning( + "Provider interrupt/restart found no live session; treating as already stopped", + { threadId: input.threadId, - operation: "steer", + operation: input.operation, + providerSessionId: input.providerSessionId, providerTurnId: input.providerTurnId, - turnCompleted: providerTurn.status === "completed", - cause: "The provider turn ended before the steering message was delivered.", - }); - } + providerTurnStatus: providerTurn.status, + }, + ); return { context, providerThread: interruptProviderThread, @@ -146,43 +168,19 @@ export const layer: Layer.Layer< session: Option.none(), }; } - const session = yield* sessions.get(input.providerSessionId); - if (Option.isNone(session)) { - // Interrupt/restart against a already-released session must not fail - // the durable effect (and retry 5x). The turn may still look running - // in projection until recovery/finalization; there is no live adapter - // to interrupt. - if (input.operation === "interrupt" || input.operation === "restart") { - yield* Effect.logWarning( - "Provider interrupt/restart found no live session; treating as already stopped", - { - threadId: input.threadId, - operation: input.operation, - providerSessionId: input.providerSessionId, - providerTurnId: input.providerTurnId, - providerTurnStatus: providerTurn.status, - }, - ); - return { - context, - providerThread: interruptProviderThread, - providerTurn, - session: Option.none(), - }; - } - return yield* new ProviderTurnControlError({ - threadId: input.threadId, - operation: input.operation, - providerTurnId: input.providerTurnId, - cause: `Provider session ${input.providerSessionId} is not active.`, - }); - } - return { context, providerThread: interruptProviderThread, providerTurn, session }; - }); + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: input.operation, + providerTurnId: input.providerTurnId, + cause: `Provider session ${input.providerSessionId} is not active.`, + }); + } + return { context, providerThread: interruptProviderThread, providerTurn, session }; + }); return ProviderTurnControlServiceV2.of({ - interrupt: (input) => - Effect.gen(function* () { + interrupt: Effect.fn("ProviderTurnControlService.interrupt")( + function* (input) { const loaded = yield* load({ ...input, operation: "interrupt" }); const session = Option.isSome(loaded.session) ? loaded.session @@ -241,20 +239,23 @@ export const layer: Layer.Layer< yield* Effect.sleep("10 millis"); } }).pipe(Effect.provideService(Clock.Clock, Clock.Clock.defaultValue())); - }).pipe( - Effect.mapError((cause) => - isProviderTurnControlError(cause) - ? cause - : new ProviderTurnControlError({ - threadId: input.threadId, - operation: "interrupt", - providerTurnId: input.providerTurnId, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isProviderTurnControlError(cause) + ? cause + : new ProviderTurnControlError({ + threadId: input.threadId, + operation: "interrupt", + providerTurnId: input.providerTurnId, + cause, + }), + ), ), - ), - interruptAndAwaitTerminal: (input) => - Effect.gen(function* () { + ), + interruptAndAwaitTerminal: Effect.fn("ProviderTurnControlService.interruptAndAwaitTerminal")( + 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 @@ -307,20 +308,23 @@ export const layer: Layer.Layer< providerTurnId: input.providerTurnId, cause: `Provider turn ${input.providerTurnId} did not terminalize before restart.`, }); - }).pipe( - Effect.mapError((cause) => - isProviderTurnControlError(cause) - ? cause - : new ProviderTurnControlError({ - threadId: input.threadId, - operation: "restart", - providerTurnId: input.providerTurnId, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isProviderTurnControlError(cause) + ? cause + : new ProviderTurnControlError({ + threadId: input.threadId, + operation: "restart", + providerTurnId: input.providerTurnId, + cause, + }), + ), ), - ), - steer: (input) => - Effect.gen(function* () { + ), + steer: Effect.fn("ProviderTurnControlService.steer")( + function* (input) { const context = yield* projections.getProviderControlContext(input.threadId, input); const ownership = context.message?.delegatedCompletion; if (ownership !== undefined) { @@ -389,18 +393,21 @@ export const layer: Layer.Layer< }), ), ); - }).pipe( - Effect.mapError((cause) => - isProviderTurnControlError(cause) - ? cause - : new ProviderTurnControlError({ - threadId: input.threadId, - operation: "steer", - providerTurnId: input.providerTurnId, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isProviderTurnControlError(cause) + ? cause + : new ProviderTurnControlError({ + threadId: input.threadId, + operation: "steer", + providerTurnId: input.providerTurnId, + cause, + }), + ), ), - ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index a9e0e35e6969..1f57b2a8a40a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -541,12 +541,12 @@ export const layer: Layer.Layer< }), }), ); - const settleStartFailure = (failed: { - readonly signal: string; - readonly title: string; - readonly error: Error; - }) => - Effect.gen(function* () { + const settleStartFailure = Effect.fn("ProviderTurnStartService.settleStartFailure")( + function* (failed: { + readonly signal: string; + readonly title: string; + readonly error: Error; + }) { const nestedCause = "cause" in failed.error ? failed.error.cause : undefined; yield* settleRunBeforeStart({ signal: failed.signal, @@ -569,7 +569,8 @@ export const layer: Layer.Layer< }), }, }); - }); + }, + ); if (sessionResult._tag === "Failure") { if (input.willRetry === true) return yield* sessionResult.failure; yield* settleStartFailure({ @@ -581,20 +582,19 @@ export const layer: Layer.Layer< } const session = sessionResult.success; if (!(yield* isCurrentAttemptInStatus("starting"))) return; - const loadFromProvider = ( + const loadFromProvider = Effect.fn("ProviderTurnStartService.loadFromProvider")(function* ( load: Effect.Effect, - ) => - Effect.gen(function* () { - const loaded = yield* Effect.result(load); - if (loaded._tag === "Success") return loaded.success; - if (input.willRetry === true) return yield* loaded.failure; - yield* settleStartFailure({ - signal: "provider-thread-load-failure", - title: "Provider turn failed to start", - error: loaded.failure, - }); - return undefined; + ) { + const loaded = yield* Effect.result(load); + if (loaded._tag === "Success") return loaded.success; + if (input.willRetry === true) return yield* loaded.failure; + yield* settleStartFailure({ + signal: "provider-thread-load-failure", + title: "Provider turn failed to start", + error: loaded.failure, }); + return undefined; + }); let effectiveHandoffs = handoffs; const loadedProviderThread = yield* Effect.gen(function* () { if (nativeForkTransfer !== undefined) { @@ -1073,11 +1073,8 @@ export const layer: Layer.Layer< !coveredItemIds.has(item.id) && historicalMessage(item) !== null, ); - const startWithHandoffs = ( - turnInput: Parameters[0], - compact = false, - ) => - Effect.gen(function* () { + const startWithHandoffs = Effect.fn("ProviderTurnStartService.startWithHandoffs")( + function* (turnInput: Parameters[0], compact = false) { const retryHandoff = missedItems.length === 0 ? [] @@ -1127,25 +1124,24 @@ export const layer: Layer.Layer< ...history, }), }), - persist: (handoff) => - Effect.gen(function* () { - const updatedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: yield* idAllocator.allocate.event({ - threadId: projection.thread.id, - }), - type: "context-handoff.updated", + persist: Effect.fn("ProviderTurnStartService.persist")(function* (handoff) { + const updatedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: updatedAt, - payload: { ...handoff, updatedAt }, - }, - ], - }); - }), + }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: updatedAt, + payload: { ...handoff, updatedAt }, + }, + ], + }); + }), }); if (!(yield* isCurrentAttemptInStatus("running"))) return; const start = compact ? session.compactThread! : session.startTurn; @@ -1184,19 +1180,19 @@ export const layer: Layer.Layer< }), ), ); - }).pipe( - Effect.mapError((cause) => - cause._tag === "ProviderAdapterTurnStartError" - ? cause - : new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: session.driver, - threadId: projection.thread.id, - providerThreadId: providerThread.id, - runId: run.id, - cause, - }), - ), - ); + }, + Effect.mapError((cause) => + cause._tag === "ProviderAdapterTurnStartError" + ? cause + : new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: session.driver, + threadId: projection.thread.id, + providerThreadId: providerThread.id, + runId: run.id, + cause, + }), + ), + ); const deliverySession = effectiveHandoffs.length === 0 && missedItems.length === 0 && @@ -1256,8 +1252,8 @@ export const layer: Layer.Layer< }); return ProviderTurnStartServiceV2.of({ - start: (input) => - Effect.gen(function* () { + start: Effect.fn("ProviderTurnStartService.start")( + function* (input) { // Storage cleanup removes worktrees under the same lease. Only startup // holds it; the provider turn runs after it is released. const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []); @@ -1265,13 +1261,16 @@ export const layer: Layer.Layer< ? start(input) : withWorkspaceLease(path.resolve(thread.worktreePath), start(input)); if (providerTurn !== undefined) yield* providerTurn; - }).pipe( - Effect.mapError((cause) => - isProviderTurnStartError(cause) - ? cause - : new ProviderTurnStartError({ runId: input.runId, cause }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isProviderTurnStartError(cause) + ? cause + : new ProviderTurnStartError({ runId: input.runId, cause }), + ), ), - ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 165e08407e19..6589d51d56d4 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -218,23 +218,26 @@ export const make = Effect.gen(function* () { lives.set(lifeKey(target), { ...life, wakes: life.wakes + 1 }); }); - const reportEnd = (key: string, life: WatchLife, reason: WatchEndReason) => - Effect.gen(function* () { - lives.delete(key); - const now = yield* Clock.currentTimeMillis; - const quietMs = now - life.pushedAt; - yield* Effect.logInfo("pull request watch ended", { - threadId: life.threadId, - pullRequest: life.pullRequest, - reason, - minutes: minutes(now - life.startedAt), - quietMinutes: minutes(quietMs), - longestQuietMinutes: minutes(Math.max(life.longestQuietMs, quietMs)), - wakes: life.wakes, - reads: life.reads, - partial: life.startedAt < bootedAt, - }); + const reportEnd = Effect.fn("PullRequestWatchReactor.reportEnd")(function* ( + key: string, + life: WatchLife, + reason: WatchEndReason, + ) { + lives.delete(key); + const now = yield* Clock.currentTimeMillis; + const quietMs = now - life.pushedAt; + yield* Effect.logInfo("pull request watch ended", { + threadId: life.threadId, + pullRequest: life.pullRequest, + reason, + minutes: minutes(now - life.startedAt), + quietMinutes: minutes(quietMs), + longestQuietMinutes: minutes(Math.max(life.longestQuietMs, quietMs)), + wakes: life.wakes, + reads: life.reads, + partial: life.startedAt < bootedAt, }); + }); const ended = (target: WatchTarget, reason: WatchEndReason) => Effect.suspend(() => reportEnd(lifeKey(target), lifeOf(target), reason)); @@ -255,27 +258,26 @@ export const make = Effect.gen(function* () { * The orchestrator applies this only while the same watch is on, so a stop or restart * that lands during the host read wins. */ - const record = ( + const record = Effect.fn("PullRequestWatchReactor.record")(function* ( target: WatchTarget, next: ThreadPullRequestWatch | null, wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, endedWatch?: ThreadPullRequestWatch, - ) => - Effect.gen(function* () { - const uuid = yield* crypto.randomUUIDv4(); - yield* engine.dispatch({ - type: "thread.pull-request-watch.sync", - commandId: CommandId.make(`server:pr-watch:${target.thread.id}:${uuid}`), - threadId: target.thread.id, - ...identityOf(target.link), - startedAt: target.watch.startedAt, - watch: next, - ...(endedWatch === undefined ? {} : { endedWatch }), - ...(wake === undefined - ? {} - : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), - }); + ) { + const uuid = yield* crypto.randomUUIDv4(); + yield* engine.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`server:pr-watch:${target.thread.id}:${uuid}`), + threadId: target.thread.id, + ...identityOf(target.link), + startedAt: target.watch.startedAt, + watch: next, + ...(endedWatch === undefined ? {} : { endedWatch }), + ...(wake === undefined + ? {} + : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), }); + }); const giveUp = (target: WatchTarget) => record(target, null, { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 9abe812c3d12..74916428333f 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -201,120 +201,120 @@ function withLinkedChildThreadId( return { ...current, linkedChildThreadIds }; } -export function cascadeTerminalizeRunOwnedSubagents(input: { +export const cascadeTerminalizeRunOwnedSubagents = Effect.fn( + "RunExecutionService.cascadeTerminalizeRunOwnedSubagents", +)(function* (input: { readonly run: OrchestrationV2Run; readonly open: OpenRunOwnedSubagentProjection; readonly status: RunOwnedSubagentTerminalStatus; readonly completedAt: DateTime.Utc; readonly allocateEventId: () => Effect.Effect; -}): Effect.Effect< +}): Effect.fn.Return< ReadonlyArray, IdAllocator.IdAllocatorV2AllocationError > { - return Effect.gen(function* () { - const events: Array = []; - // Prefer lifetime linkage over currently-open rows: subagent/turn-item - // snapshots may terminalize before the linked child-thread node settles. - const childThreadIds = new Set(input.open.linkedChildThreadIds); - for (const item of [...input.open.subagents.values(), ...input.open.turnItems.values()]) { - if (item.childThreadId !== null) { - childThreadIds.add(item.childThreadId); - } + const events: Array = []; + // Prefer lifetime linkage over currently-open rows: subagent/turn-item + // snapshots may terminalize before the linked child-thread node settles. + const childThreadIds = new Set(input.open.linkedChildThreadIds); + for (const item of [...input.open.subagents.values(), ...input.open.turnItems.values()]) { + if (item.childThreadId !== null) { + childThreadIds.add(item.childThreadId); } - const keys = new Set([ - ...input.open.subagents.keys(), - ...input.open.turnItems.keys(), - ...input.open.nodes.keys(), - ]); - for (const key of keys) { - const subagent = input.open.subagents.get(key); - if (subagent !== undefined && !isSettledSubagentStatus(subagent.status)) { - events.push({ - id: yield* input.allocateEventId(), - type: "subagent.updated", - threadId: subagent.threadId, - runId: input.run.id, - nodeId: subagent.id, - driver: subagent.driver, - providerInstanceId: subagent.providerInstanceId, - occurredAt: input.completedAt, - payload: { - ...subagent, - status: input.status, - completedAt: input.completedAt, - updatedAt: input.completedAt, - }, - }); - } - const node = input.open.nodes.get(key); - if ( - node !== undefined && - ((node.threadId === input.run.threadId && node.runId === input.run.id) || - childThreadIds.has(node.threadId)) && - isOpenExecutionNodeStatus(node.status) - ) { - events.push({ - id: yield* input.allocateEventId(), - type: "node.updated", - threadId: node.threadId, - runId: node.runId ?? input.run.id, - nodeId: node.id, - providerInstanceId: input.run.providerInstanceId, - occurredAt: input.completedAt, - payload: { - ...node, - status: input.status, - completedAt: input.completedAt, - }, - }); - } - const turnItem = input.open.turnItems.get(key); - if ( - turnItem !== undefined && - turnItem.runId === input.run.id && - !isSettledTurnItemStatus(turnItem.status) - ) { - events.push({ - id: yield* input.allocateEventId(), - type: "turn-item.updated", - threadId: turnItem.threadId, - runId: input.run.id, - ...(turnItem.nodeId === null ? {} : { nodeId: turnItem.nodeId }), - providerInstanceId: input.run.providerInstanceId, - occurredAt: input.completedAt, - payload: { - ...turnItem, - status: input.status, - completedAt: input.completedAt, - updatedAt: input.completedAt, - }, - }); - } + } + const keys = new Set([ + ...input.open.subagents.keys(), + ...input.open.turnItems.keys(), + ...input.open.nodes.keys(), + ]); + for (const key of keys) { + const subagent = input.open.subagents.get(key); + if (subagent !== undefined && !isSettledSubagentStatus(subagent.status)) { + events.push({ + id: yield* input.allocateEventId(), + type: "subagent.updated", + threadId: subagent.threadId, + runId: input.run.id, + nodeId: subagent.id, + driver: subagent.driver, + providerInstanceId: subagent.providerInstanceId, + occurredAt: input.completedAt, + payload: { + ...subagent, + status: input.status, + completedAt: input.completedAt, + updatedAt: input.completedAt, + }, + }); } - for (const turnItem of input.open.childTurnItems.values()) { - if (!childThreadIds.has(turnItem.threadId) || isSettledTurnItemStatus(turnItem.status)) { - continue; - } + const node = input.open.nodes.get(key); + if ( + node !== undefined && + ((node.threadId === input.run.threadId && node.runId === input.run.id) || + childThreadIds.has(node.threadId)) && + isOpenExecutionNodeStatus(node.status) + ) { + events.push({ + id: yield* input.allocateEventId(), + type: "node.updated", + threadId: node.threadId, + runId: node.runId ?? input.run.id, + nodeId: node.id, + providerInstanceId: input.run.providerInstanceId, + occurredAt: input.completedAt, + payload: { + ...node, + status: input.status, + completedAt: input.completedAt, + }, + }); + } + const turnItem = input.open.turnItems.get(key); + if ( + turnItem !== undefined && + turnItem.runId === input.run.id && + !isSettledTurnItemStatus(turnItem.status) + ) { events.push({ id: yield* input.allocateEventId(), type: "turn-item.updated", threadId: turnItem.threadId, - runId: turnItem.runId ?? input.run.id, + runId: input.run.id, ...(turnItem.nodeId === null ? {} : { nodeId: turnItem.nodeId }), providerInstanceId: input.run.providerInstanceId, occurredAt: input.completedAt, payload: { ...turnItem, - ...("streaming" in turnItem ? { streaming: false } : {}), status: input.status, completedAt: input.completedAt, updatedAt: input.completedAt, }, }); } - return events; - }); -} + } + for (const turnItem of input.open.childTurnItems.values()) { + if (!childThreadIds.has(turnItem.threadId) || isSettledTurnItemStatus(turnItem.status)) { + continue; + } + events.push({ + id: yield* input.allocateEventId(), + type: "turn-item.updated", + threadId: turnItem.threadId, + runId: turnItem.runId ?? input.run.id, + ...(turnItem.nodeId === null ? {} : { nodeId: turnItem.nodeId }), + providerInstanceId: input.run.providerInstanceId, + occurredAt: input.completedAt, + payload: { + ...turnItem, + ...("streaming" in turnItem ? { streaming: false } : {}), + status: input.status, + completedAt: input.completedAt, + updatedAt: input.completedAt, + }, + }); + } + return events; +}); export function finalProviderThreadStatus( disposition: ProviderTerminalEvent["threadDisposition"], @@ -582,24 +582,24 @@ export const layer: Layer.Layer< const turnItemPositions = yield* TurnItemPositionStore.TurnItemPositionStoreV2; const finalizationObserver = yield* RunFinalizationService.RunFinalizationObserver; - const writeFinalRunEvents = (input: { - readonly run: OrchestrationV2Run; - readonly rootNode: OrchestrationV2ExecutionNode; - readonly checkpointScope: OrchestrationV2CheckpointScope; - readonly providerThread: OrchestrationV2ProviderThread; - readonly attempt: OrchestrationV2RunAttempt; - readonly shouldFinalizeRun?: () => Effect.Effect; - readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; - readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; - readonly terminal: ProviderTerminalEvent; - readonly failureItemPersisted: boolean; - readonly refreshAfterTurn: Effect.Effect; - readonly writeIfRunCurrent?: { - readonly activeAttemptId: RunAttemptId; - readonly expectedStatus: OrchestrationV2Run["status"]; - }; - }) => - Effect.gen(function* () { + const writeFinalRunEvents = Effect.fn("RunExecutionService.writeFinalRunEvents")( + function* (input: { + readonly run: OrchestrationV2Run; + readonly rootNode: OrchestrationV2ExecutionNode; + readonly checkpointScope: OrchestrationV2CheckpointScope; + readonly providerThread: OrchestrationV2ProviderThread; + readonly attempt: OrchestrationV2RunAttempt; + readonly shouldFinalizeRun?: () => Effect.Effect; + readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; + readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; + readonly terminal: ProviderTerminalEvent; + readonly failureItemPersisted: boolean; + readonly refreshAfterTurn: Effect.Effect; + readonly writeIfRunCurrent?: { + readonly activeAttemptId: RunAttemptId; + readonly expectedStatus: OrchestrationV2Run["status"]; + }; + }) { const completedAt = yield* DateTime.now; const finalizedAttempt: OrchestrationV2RunAttempt | null = { ...input.attempt, @@ -816,697 +816,690 @@ export const layer: Layer.Layer< yield* eventSink.writeWithEffects(finalization); } yield* input.refreshAfterTurn; - }); + }, + ); return RunExecutionServiceV2.of({ - startRootRun: (input) => - Effect.gen(function* () { - // Startup failure and stream shutdown can report the same attempt. - const refreshAfterTurn = yield* Effect.cached( - finalizationObserver.refreshAfterTurn(input.appThread.projectId).pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to refresh pull requests after run termination", { - threadId: input.run.threadId, - runId: input.run.id, - cause, - }), - ), - ), - ); - const makeFailedTerminalEvent = ( - failure: OrchestrationV2ProviderFailure, - failureItemOrdinal: number, - ): ProviderTerminalEvent => ({ - type: "turn.terminal", - driver: input.providerThread.driver, - providerThreadId: input.providerThread.id, - providerTurnId: - input.attempt.providerTurnId ?? - idAllocator.derive.providerTurn({ - driver: input.providerThread.driver, - nativeTurnId: `failed:${input.attempt.id}`, - }), - runOrdinal: input.run.ordinal, - failureItemOrdinal, - status: "failed", - failure, - threadDisposition: "reusable", - }); - const responseStreamingMode = yield* Effect.gen(function* () { - const responseStreamingMode = yield* serverSettings.getSettings.pipe( - Effect.map( - (settings) => - resolveProjectSettings(settings, input.appThread.projectId).settings - .responseStreamingMode, - ), - ); - yield* checkpointService - .captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - .pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), - ), - ); - if ( - input.shouldStartProviderTurn !== undefined && - !(yield* input.shouldStartProviderTurn()) - ) { - return null; - } - return responseStreamingMode; - }).pipe( + startRootRun: Effect.fn("RunExecutionService.startRootRun")(function* (input) { + // Startup failure and stream shutdown can report the same attempt. + const refreshAfterTurn = yield* Effect.cached( + finalizationObserver.refreshAfterTurn(input.appThread.projectId).pipe( Effect.catchCause((cause) => - Effect.gen(function* () { - if (Cause.hasInterruptsOnly(cause)) { - return yield* Effect.failCause(cause); - } - yield* Effect.logError("orchestration V2 run preparation failed", { - runId: input.run.id, - cause, - }); - yield* writeFinalRunEvents({ - run: input.run, - rootNode: input.rootNode, - checkpointScope: input.checkpointScope, - providerThread: input.providerThread, - attempt: input.attempt, - terminal: makeFailedTerminalEvent( - makeProviderFailure({ - cause: Cause.squash(cause), - message: "Run preparation failed.", - class: "unknown", - }), - input.providerTurnOrdinal * 100 + 1, - ), - failureItemPersisted: false, - refreshAfterTurn, - writeIfRunCurrent: { - activeAttemptId: input.attemptId, - expectedStatus: "running", - }, - }); - return null; + Effect.logWarning("failed to refresh pull requests after run termination", { + threadId: input.run.threadId, + runId: input.run.id, + cause, }), ), - Effect.mapError( - (cause) => - new RunExecutionStartError({ - commandId: input.commandId, - runId: input.run.id, - cause, - }), + ), + ); + const makeFailedTerminalEvent = ( + failure: OrchestrationV2ProviderFailure, + failureItemOrdinal: number, + ): ProviderTerminalEvent => ({ + type: "turn.terminal", + driver: input.providerThread.driver, + providerThreadId: input.providerThread.id, + providerTurnId: + input.attempt.providerTurnId ?? + idAllocator.derive.providerTurn({ + driver: input.providerThread.driver, + nativeTurnId: `failed:${input.attempt.id}`, + }), + runOrdinal: input.run.ordinal, + failureItemOrdinal, + status: "failed", + failure, + threadDisposition: "reusable", + }); + const responseStreamingMode = yield* Effect.gen(function* () { + const responseStreamingMode = yield* serverSettings.getSettings.pipe( + Effect.map( + (settings) => + resolveProjectSettings(settings, input.appThread.projectId).settings + .responseStreamingMode, ), ); - if (responseStreamingMode === null) { - return; + yield* checkpointService + .captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning( + "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", + { runId: input.run.id }, + ), + ), + ); + if ( + input.shouldStartProviderTurn !== undefined && + !(yield* input.shouldStartProviderTurn()) + ) { + return null; } - const terminalEvent = yield* Ref.make(null); - const latestTurnItemOrdinal = yield* Ref.make(input.providerTurnOrdinal * 100); - const latestProviderThread = yield* Ref.make(input.providerThread); - const routeIdentity: ProviderEventRouteIdentity = { - threadId: input.run.threadId, - runId: input.run.id, - attemptId: input.attempt.id, - providerThreadId: input.providerThread.id, - }; - const eventSubscription = - input.session.subscribeEvents === undefined - ? { events: input.session.events, close: Effect.void } - : yield* input.session.subscribeEvents; - const inheritedBackgroundTurnItems = yield* ( - input.loadInheritedBackgroundTurnItems?.() ?? Effect.succeed([]) - ).pipe( - Effect.onError(() => eventSubscription.close), - Effect.mapError( - (cause) => - new RunExecutionStartError({ - commandId: input.commandId, - runId: input.run.id, - cause, - }), - ), - ); - const inheritedBackgroundTurnItemsById = new Map( - inheritedBackgroundTurnItems.map((item) => [item.id, item.runId]), - ); - const eventRouting = yield* Ref.make( - makeProviderEventRoutingState({ - identity: routeIdentity, - inheritedBackgroundTurnItems, - providerTurnId: input.attempt.providerTurnId, - ...(input.relatedThreadIds === undefined - ? {} - : { relatedThreadIds: input.relatedThreadIds }), - ...(input.relatedProviderThreadIds === undefined - ? {} - : { relatedProviderThreadIds: input.relatedProviderThreadIds }), - }), - ); - const rootTerminalSeen = yield* Ref.make(false); - const rootRunFinalized = yield* Ref.make(false); - const providerThreadOwnerLost = yield* Ref.make(false); - const activeChildProviderTurns = yield* Ref.make>(new Set()); - const activeChildSubagents = yield* Ref.make>(new Set()); - const activeBackgroundTurnItems = yield* Ref.make< - ReadonlySet - >(new Set(inheritedBackgroundTurnItemsById.keys())); - const openRunOwnedSubagents = yield* Ref.make(emptyOpenRunOwnedSubagentProjection()); - const finalizeRootRun = (terminal: ProviderTerminalEvent) => + return responseStreamingMode; + }).pipe( + Effect.catchCause((cause) => Effect.gen(function* () { - if (yield* Ref.get(rootRunFinalized)) { - return; + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); } - const providerThread = yield* Ref.get(latestProviderThread); - const openSubagents = yield* Ref.get(openRunOwnedSubagents); + yield* Effect.logError("orchestration V2 run preparation failed", { + runId: input.run.id, + cause, + }); yield* writeFinalRunEvents({ run: input.run, rootNode: input.rootNode, checkpointScope: input.checkpointScope, - providerThread, + providerThread: input.providerThread, attempt: input.attempt, - ...(input.shouldFinalizeRun === undefined - ? {} - : { - shouldFinalizeRun: input.shouldFinalizeRun, - // Stop may commit between the ownership read and this - // terminal write. Gate the events and checkpoint together. - writeIfRunCurrent: { - activeAttemptId: input.attempt.id, - expectedStatus: "running" as const, - }, - }), - ...(input.hasUnpairedRunInterruptRequest === undefined - ? {} - : { - hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, - }), - openRunOwnedSubagents: openSubagents, - terminal, - failureItemPersisted: terminal.status === "failed", - refreshAfterTurn, - }).pipe( - Effect.mapError( - (cause) => new RunExecutionIngestError({ runId: input.run.id, cause }), + terminal: makeFailedTerminalEvent( + makeProviderFailure({ + cause: Cause.squash(cause), + message: "Run preparation failed.", + class: "unknown", + }), + input.providerTurnOrdinal * 100 + 1, ), - ); - if ( - isRunOwnedSubagentTerminalStatus(terminal.status) && - !childTurnsContinue(terminal) - ) { - yield* Ref.set(openRunOwnedSubagents, emptyOpenRunOwnedSubagentProjection()); - } - yield* Ref.set(rootRunFinalized, true); - }); - const trackChildLifecycle = ( - event: ProviderAdapter.ProviderAdapterV2Event, - deliverable: boolean, - ) => - Effect.gen(function* () { - const routing = yield* Ref.get(eventRouting); - if (event.type === "provider_turn.updated") { - const isRoot = - event.providerTurn.runAttemptId === input.attempt.id || - event.providerTurn.id === routing.rootProviderTurnId; - if (!isRoot) { - yield* Ref.update(activeChildProviderTurns, (current) => { - const next = new Set(current); - if (isTerminalProviderTurnStatus(event.providerTurn.status)) { - next.delete(event.providerTurn.id); - } else { - next.add(event.providerTurn.id); - } - return next; - }); - } - } - if (event.type === "subagent.updated") { - const belongsToRootRun = event.subagent.runId === input.run.id; - const belongsToOwnedChildThread = - event.subagent.threadId !== input.run.threadId && - routing.ownedThreadIds.has(event.subagent.threadId); - if (belongsToRootRun || belongsToOwnedChildThread) { - yield* Ref.update(activeChildSubagents, (current) => { - const next = new Set(current); - if (isSettledSubagentStatus(event.subagent.status)) { - next.delete(event.subagent.id); - } else { - next.add(event.subagent.id); - } - return next; - }); - } - // Preserve childThreadId linkage for the root-run lifetime even after the subagent - // row terminalizes, so open child-thread nodes can still be proven linked on a - // later root interrupt. A provider records nested native subagents on the child - // thread, runless; they die with this run too. - if ( - belongsToRootRun || - (belongsToOwnedChildThread && - event.subagent.runId === null && - event.subagent.origin === "provider_native") - ) { - yield* Ref.update(openRunOwnedSubagents, (current) => { - const withLink = withLinkedChildThreadId(current, event.subagent.childThreadId); - const subagents = new Map(withLink.subagents); - if (isSettledSubagentStatus(event.subagent.status)) { - subagents.delete(event.subagent.id); - } else { - subagents.set(event.subagent.id, event.subagent); - } - return { ...withLink, subagents }; - }); - } - } - if (event.type === "node.updated") { - const belongsToRootSubagent = - event.node.kind === "subagent" && event.node.runId === input.run.id; - const belongsToOwnedChildThread = - event.node.threadId !== input.run.threadId && - routing.ownedThreadIds.has(event.node.threadId); - if ( - !belongsToRootSubagent && - !( - belongsToOwnedChildThread && - (event.node.runId === null || event.node.runId === input.run.id) - ) - ) { - return; - } - yield* Ref.update(openRunOwnedSubagents, (current) => { - const nodes = new Map(current.nodes); - if (isOpenExecutionNodeStatus(event.node.status)) { - nodes.set(event.node.id, event.node); - } else { - nodes.delete(event.node.id); - } - return { ...current, nodes }; - }); - } - if (event.type === "turn_item.updated") { - const belongsToRootRun = event.turnItem.runId === input.run.id; - const belongsToOwnedChildThread = - event.turnItem.threadId !== input.run.threadId && - routing.ownedThreadIds.has(event.turnItem.threadId); - const belongsToInheritedBackgroundItem = - event.turnItem.threadId === input.run.threadId && - event.turnItem.runId !== null && - inheritedBackgroundTurnItemsById.get(event.turnItem.id) === event.turnItem.runId; - if ( - backgroundCapableTurnItemTypes.has(event.turnItem.type) && - (belongsToRootRun || - belongsToOwnedChildThread || - belongsToInheritedBackgroundItem) - ) { - yield* Ref.update(activeBackgroundTurnItems, (current) => { - const next = new Set(current); - if (isSettledTurnItemStatus(event.turnItem.status)) { - next.delete(event.turnItem.id); - } else { - next.add(event.turnItem.id); - } - return next; - }); + failureItemPersisted: false, + refreshAfterTurn, + writeIfRunCurrent: { + activeAttemptId: input.attemptId, + expectedStatus: "running", + }, + }); + return null; + }), + ), + Effect.mapError( + (cause) => + new RunExecutionStartError({ + commandId: input.commandId, + runId: input.run.id, + cause, + }), + ), + ); + if (responseStreamingMode === null) { + return; + } + const terminalEvent = yield* Ref.make(null); + const latestTurnItemOrdinal = yield* Ref.make(input.providerTurnOrdinal * 100); + const latestProviderThread = yield* Ref.make(input.providerThread); + const routeIdentity: ProviderEventRouteIdentity = { + threadId: input.run.threadId, + runId: input.run.id, + attemptId: input.attempt.id, + providerThreadId: input.providerThread.id, + }; + const eventSubscription = + input.session.subscribeEvents === undefined + ? { events: input.session.events, close: Effect.void } + : yield* input.session.subscribeEvents; + const inheritedBackgroundTurnItems = yield* ( + input.loadInheritedBackgroundTurnItems?.() ?? Effect.succeed([]) + ).pipe( + Effect.onError(() => eventSubscription.close), + Effect.mapError( + (cause) => + new RunExecutionStartError({ + commandId: input.commandId, + runId: input.run.id, + cause, + }), + ), + ); + const inheritedBackgroundTurnItemsById = new Map( + inheritedBackgroundTurnItems.map((item) => [item.id, item.runId]), + ); + const eventRouting = yield* Ref.make( + makeProviderEventRoutingState({ + identity: routeIdentity, + inheritedBackgroundTurnItems, + providerTurnId: input.attempt.providerTurnId, + ...(input.relatedThreadIds === undefined + ? {} + : { relatedThreadIds: input.relatedThreadIds }), + ...(input.relatedProviderThreadIds === undefined + ? {} + : { relatedProviderThreadIds: input.relatedProviderThreadIds }), + }), + ); + const rootTerminalSeen = yield* Ref.make(false); + const rootRunFinalized = yield* Ref.make(false); + const providerThreadOwnerLost = yield* Ref.make(false); + const activeChildProviderTurns = yield* Ref.make>(new Set()); + const activeChildSubagents = yield* Ref.make>(new Set()); + const activeBackgroundTurnItems = yield* Ref.make< + ReadonlySet + >(new Set(inheritedBackgroundTurnItemsById.keys())); + const openRunOwnedSubagents = yield* Ref.make(emptyOpenRunOwnedSubagentProjection()); + const finalizeRootRun = Effect.fn("RunExecutionService.finalizeRootRun")(function* ( + terminal: ProviderTerminalEvent, + ) { + if (yield* Ref.get(rootRunFinalized)) { + return; + } + const providerThread = yield* Ref.get(latestProviderThread); + const openSubagents = yield* Ref.get(openRunOwnedSubagents); + yield* writeFinalRunEvents({ + run: input.run, + rootNode: input.rootNode, + checkpointScope: input.checkpointScope, + providerThread, + attempt: input.attempt, + ...(input.shouldFinalizeRun === undefined + ? {} + : { + shouldFinalizeRun: input.shouldFinalizeRun, + // Stop may commit between the ownership read and this + // terminal write. Gate the events and checkpoint together. + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running" as const, + }, + }), + ...(input.hasUnpairedRunInterruptRequest === undefined + ? {} + : { + hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, + }), + openRunOwnedSubagents: openSubagents, + terminal, + failureItemPersisted: terminal.status === "failed", + refreshAfterTurn, + }).pipe( + Effect.mapError((cause) => new RunExecutionIngestError({ runId: input.run.id, cause })), + ); + if (isRunOwnedSubagentTerminalStatus(terminal.status) && !childTurnsContinue(terminal)) { + yield* Ref.set(openRunOwnedSubagents, emptyOpenRunOwnedSubagentProjection()); + } + yield* Ref.set(rootRunFinalized, true); + }); + // Runs for every provider event, including streamed text updates; avoid a per-event span. + const trackChildLifecycle = Effect.fnUntraced(function* ( + event: ProviderAdapter.ProviderAdapterV2Event, + deliverable: boolean, + ) { + const routing = yield* Ref.get(eventRouting); + if (event.type === "provider_turn.updated") { + const isRoot = + event.providerTurn.runAttemptId === input.attempt.id || + event.providerTurn.id === routing.rootProviderTurnId; + if (!isRoot) { + yield* Ref.update(activeChildProviderTurns, (current) => { + const next = new Set(current); + if (isTerminalProviderTurnStatus(event.providerTurn.status)) { + next.delete(event.providerTurn.id); + } else { + next.add(event.providerTurn.id); } - if ( - belongsToOwnedChildThread && - (event.turnItem.runId === null || belongsToRootRun) && - deliverable - ) { - yield* Ref.update(openRunOwnedSubagents, (current) => { - const childTurnItems = new Map(current.childTurnItems); - if (isSettledTurnItemStatus(event.turnItem.status)) { - childTurnItems.delete(event.turnItem.id); - } else { - childTurnItems.set(event.turnItem.id, event.turnItem); - } - return { ...current, childTurnItems }; - }); + return next; + }); + } + } + if (event.type === "subagent.updated") { + const belongsToRootRun = event.subagent.runId === input.run.id; + const belongsToOwnedChildThread = + event.subagent.threadId !== input.run.threadId && + routing.ownedThreadIds.has(event.subagent.threadId); + if (belongsToRootRun || belongsToOwnedChildThread) { + yield* Ref.update(activeChildSubagents, (current) => { + const next = new Set(current); + if (isSettledSubagentStatus(event.subagent.status)) { + next.delete(event.subagent.id); + } else { + next.add(event.subagent.id); } - if (belongsToRootRun && event.turnItem.type === "subagent") { - const subagentItem = event.turnItem; - yield* Ref.update(openRunOwnedSubagents, (current) => { - const withLink = withLinkedChildThreadId(current, subagentItem.childThreadId); - const turnItems = new Map(withLink.turnItems); - if (isSettledTurnItemStatus(subagentItem.status)) { - turnItems.delete(subagentItem.subagentId); - } else { - turnItems.set(subagentItem.subagentId, subagentItem); - } - return { ...withLink, turnItems }; - }); + return next; + }); + } + // Preserve childThreadId linkage for the root-run lifetime even after the subagent + // row terminalizes, so open child-thread nodes can still be proven linked on a + // later root interrupt. A provider records nested native subagents on the child + // thread, runless; they die with this run too. + if ( + belongsToRootRun || + (belongsToOwnedChildThread && + event.subagent.runId === null && + event.subagent.origin === "provider_native") + ) { + yield* Ref.update(openRunOwnedSubagents, (current) => { + const withLink = withLinkedChildThreadId(current, event.subagent.childThreadId); + const subagents = new Map(withLink.subagents); + if (isSettledSubagentStatus(event.subagent.status)) { + subagents.delete(event.subagent.id); + } else { + subagents.set(event.subagent.id, event.subagent); } + return { ...withLink, subagents }; + }); + } + } + if (event.type === "node.updated") { + const belongsToRootSubagent = + event.node.kind === "subagent" && event.node.runId === input.run.id; + const belongsToOwnedChildThread = + event.node.threadId !== input.run.threadId && + routing.ownedThreadIds.has(event.node.threadId); + if ( + !belongsToRootSubagent && + !( + belongsToOwnedChildThread && + (event.node.runId === null || event.node.runId === input.run.id) + ) + ) { + return; + } + yield* Ref.update(openRunOwnedSubagents, (current) => { + const nodes = new Map(current.nodes); + if (isOpenExecutionNodeStatus(event.node.status)) { + nodes.set(event.node.id, event.node); + } else { + nodes.delete(event.node.id); } + return { ...current, nodes }; }); - const shouldStopProviderEventIngestion = Effect.gen(function* () { - if (!(yield* Ref.get(rootTerminalSeen))) { - return false; - } - const terminal = yield* Ref.get(terminalEvent); + } + if (event.type === "turn_item.updated") { + const belongsToRootRun = event.turnItem.runId === input.run.id; + const belongsToOwnedChildThread = + event.turnItem.threadId !== input.run.threadId && + routing.ownedThreadIds.has(event.turnItem.threadId); + const belongsToInheritedBackgroundItem = + event.turnItem.threadId === input.run.threadId && + event.turnItem.runId !== null && + inheritedBackgroundTurnItemsById.get(event.turnItem.id) === event.turnItem.runId; if ( - terminal !== null && - terminal.status !== "completed" && - !childTurnsContinue(terminal) + backgroundCapableTurnItemTypes.has(event.turnItem.type) && + (belongsToRootRun || belongsToOwnedChildThread || belongsToInheritedBackgroundItem) ) { - return true; + yield* Ref.update(activeBackgroundTurnItems, (current) => { + const next = new Set(current); + if (isSettledTurnItemStatus(event.turnItem.status)) { + next.delete(event.turnItem.id); + } else { + next.add(event.turnItem.id); + } + return next; + }); } - const childProviderTurns = yield* Ref.get(activeChildProviderTurns); - if (childProviderTurns.size > 0) { - return false; + if ( + belongsToOwnedChildThread && + (event.turnItem.runId === null || belongsToRootRun) && + deliverable + ) { + yield* Ref.update(openRunOwnedSubagents, (current) => { + const childTurnItems = new Map(current.childTurnItems); + if (isSettledTurnItemStatus(event.turnItem.status)) { + childTurnItems.delete(event.turnItem.id); + } else { + childTurnItems.set(event.turnItem.id, event.turnItem); + } + return { ...current, childTurnItems }; + }); } - const childSubagents = yield* Ref.get(activeChildSubagents); - if (childSubagents.size > 0) { - return false; + if (belongsToRootRun && event.turnItem.type === "subagent") { + const subagentItem = event.turnItem; + yield* Ref.update(openRunOwnedSubagents, (current) => { + const withLink = withLinkedChildThreadId(current, subagentItem.childThreadId); + const turnItems = new Map(withLink.turnItems); + if (isSettledTurnItemStatus(subagentItem.status)) { + turnItems.delete(subagentItem.subagentId); + } else { + turnItems.set(subagentItem.subagentId, subagentItem); + } + return { ...withLink, turnItems }; + }); } - // Newly owned items depend on adapters emitting a non-terminal event before the root - // terminal. Owner loss (a newer run claimed lastRunOrdinal) must not close this stream - // while these sets are non-empty: turn_item.updated writes are not ownership-gated, so - // late completions still land. - const backgroundItems = yield* Ref.get(activeBackgroundTurnItems); - if (backgroundItems.size > 0) { + } + }); + const shouldStopProviderEventIngestion = Effect.gen(function* () { + if (!(yield* Ref.get(rootTerminalSeen))) { + return false; + } + const terminal = yield* Ref.get(terminalEvent); + if ( + terminal !== null && + terminal.status !== "completed" && + !childTurnsContinue(terminal) + ) { + return true; + } + const childProviderTurns = yield* Ref.get(activeChildProviderTurns); + if (childProviderTurns.size > 0) { + return false; + } + const childSubagents = yield* Ref.get(activeChildSubagents); + if (childSubagents.size > 0) { + return false; + } + // Newly owned items depend on adapters emitting a non-terminal event before the root + // terminal. Owner loss (a newer run claimed lastRunOrdinal) must not close this stream + // while these sets are non-empty: turn_item.updated writes are not ownership-gated, so + // late completions still land. + const backgroundItems = yield* Ref.get(activeBackgroundTurnItems); + if (backgroundItems.size > 0) { + return false; + } + if (yield* Ref.get(providerThreadOwnerLost)) { + return true; + } + // Claude background Bash has no turn-item projection. Use only the + // thread-scoped probe: session-wide pending work (siblings, wake + // buffers, session subagents) must not pin this root subscription. + const latestProviderThreadSnapshot = yield* Ref.get(latestProviderThread); + if (input.session.hasPendingBackgroundWorkForThread !== undefined) { + const hasPendingWork = yield* input.session + .hasPendingBackgroundWorkForThread(latestProviderThreadSnapshot) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (hasPendingWork) { return false; } - if (yield* Ref.get(providerThreadOwnerLost)) { - return true; - } - // Claude background Bash has no turn-item projection. Use only the - // thread-scoped probe: session-wide pending work (siblings, wake - // buffers, session subagents) must not pin this root subscription. - const latestProviderThreadSnapshot = yield* Ref.get(latestProviderThread); - if (input.session.hasPendingBackgroundWorkForThread !== undefined) { - const hasPendingWork = yield* input.session - .hasPendingBackgroundWorkForThread(latestProviderThreadSnapshot) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - if (hasPendingWork) { - return false; - } - } - return true; - }); - const filterAssistantEvent = makeAssistantStreamingFilter(responseStreamingMode); - // Held text writes nothing, so its item would take a position only - // when finally written, behind every item that began after it. - const reservedHeldTurnItemIds = new Set(); - const providerEventFiber = yield* eventSubscription.events.pipe( - Stream.filterEffect((event) => - Ref.modify(eventRouting, (state) => routeProviderEvent(event, routeIdentity, state)), - ), - Stream.tap((event) => - Effect.gen(function* () { - let storedEventCount = 0; - const deliveredEvent = filterAssistantEvent( - event, - DateTime.toEpochMillis(yield* DateTime.now), - ); - if (deliveredEvent) { - const rootTerminalAlreadySeen = yield* Ref.get(rootTerminalSeen); - const isRootProviderThreadUpdate = - event.type === "provider_thread.updated" && - event.providerThread.id === input.providerThread.id; - const storedEvents = yield* providerEventIngestor.ingestNormalized({ - analyticsContext: { - modelSelection: input.modelSelection, - runtimeMode: input.runtimePolicy.runtimeMode, - interactionMode: input.runtimePolicy.interactionMode, - }, - providerSessionId: input.providerSessionId, - providerInstanceId: input.run.providerInstanceId, - threadId: input.run.threadId, - runId: input.run.id, - nodeId: input.rootNode.id, - event: deliveredEvent, - ...(isRootProviderThreadUpdate - ? rootTerminalAlreadySeen - ? { - writeIfProviderThreadOwner: { - providerThreadId: input.providerThread.id, - runId: input.run.id, - activeAttemptId: input.attempt.id, - expectedLastRunOrdinal: input.run.ordinal, - }, - } - : { - writeIfRunCurrent: { - runId: input.run.id, - activeAttemptId: input.attempt.id, - expectedStatus: "running" as const, - }, - } - : {}), - }); - storedEventCount = storedEvents.length; - if ( - isRootProviderThreadUpdate && - rootTerminalAlreadySeen && - storedEventCount === 0 - ) { - yield* Ref.set(providerThreadOwnerLost, true); - } - } else if ( - event.type === "turn_item.updated" && - (event.turnItem.type === "assistant_message" || - event.turnItem.type === "reasoning") && - event.turnItem.streaming && - !reservedHeldTurnItemIds.has(event.turnItem.id) - ) { - yield* turnItemPositions.allocate({ - threadId: event.turnItem.threadId, - turnItemId: event.turnItem.id, - runId: event.turnItem.runId, - }); - reservedHeldTurnItemIds.add(event.turnItem.id); - } - if (event.type === "provider_thread.updated") { - if (event.providerThread.id === input.providerThread.id && storedEventCount > 0) { - yield* Ref.set(latestProviderThread, event.providerThread); - } - } + } + return true; + }); + const filterAssistantEvent = makeAssistantStreamingFilter(responseStreamingMode); + // Held text writes nothing, so its item would take a position only + // when finally written, behind every item that began after it. + const reservedHeldTurnItemIds = new Set(); + const providerEventFiber = yield* eventSubscription.events.pipe( + Stream.filterEffect((event) => + Ref.modify(eventRouting, (state) => routeProviderEvent(event, routeIdentity, state)), + ), + Stream.tap((event) => + Effect.gen(function* () { + let storedEventCount = 0; + const deliveredEvent = filterAssistantEvent( + event, + DateTime.toEpochMillis(yield* DateTime.now), + ); + if (deliveredEvent) { + const rootTerminalAlreadySeen = yield* Ref.get(rootTerminalSeen); + const isRootProviderThreadUpdate = + event.type === "provider_thread.updated" && + event.providerThread.id === input.providerThread.id; + const storedEvents = yield* providerEventIngestor.ingestNormalized({ + analyticsContext: { + modelSelection: input.modelSelection, + runtimeMode: input.runtimePolicy.runtimeMode, + interactionMode: input.runtimePolicy.interactionMode, + }, + providerSessionId: input.providerSessionId, + providerInstanceId: input.run.providerInstanceId, + threadId: input.run.threadId, + runId: input.run.id, + nodeId: input.rootNode.id, + event: deliveredEvent, + ...(isRootProviderThreadUpdate + ? rootTerminalAlreadySeen + ? { + writeIfProviderThreadOwner: { + providerThreadId: input.providerThread.id, + runId: input.run.id, + activeAttemptId: input.attempt.id, + expectedLastRunOrdinal: input.run.ordinal, + }, + } + : { + writeIfRunCurrent: { + runId: input.run.id, + activeAttemptId: input.attempt.id, + expectedStatus: "running" as const, + }, + } + : {}), + }); + storedEventCount = storedEvents.length; if ( - event.type === "turn_item.updated" && - event.turnItem.providerTurnId === - (yield* Ref.get(eventRouting)).rootProviderTurnId + isRootProviderThreadUpdate && + rootTerminalAlreadySeen && + storedEventCount === 0 ) { - yield* Ref.update(latestTurnItemOrdinal, (current) => - Math.max(current, event.turnItem.ordinal), - ); + yield* Ref.set(providerThreadOwnerLost, true); } - if (event.type === "turn.terminal") { - yield* Ref.set(terminalEvent, event); - yield* Ref.set(rootTerminalSeen, true); - yield* finalizeRootRun(event); + } else if ( + event.type === "turn_item.updated" && + (event.turnItem.type === "assistant_message" || + event.turnItem.type === "reasoning") && + event.turnItem.streaming && + !reservedHeldTurnItemIds.has(event.turnItem.id) + ) { + yield* turnItemPositions.allocate({ + threadId: event.turnItem.threadId, + turnItemId: event.turnItem.id, + runId: event.turnItem.runId, + }); + reservedHeldTurnItemIds.add(event.turnItem.id); + } + if (event.type === "provider_thread.updated") { + if (event.providerThread.id === input.providerThread.id && storedEventCount > 0) { + yield* Ref.set(latestProviderThread, event.providerThread); } - yield* trackChildLifecycle(event, deliveredEvent !== null); - }), - ), - Stream.takeUntilEffect(() => shouldStopProviderEventIngestion), - Stream.runDrain, - Effect.mapError((cause) => new RunExecutionIngestError({ runId: input.run.id, cause })), - Effect.flatMap(() => - Effect.gen(function* () { - const terminal = yield* Ref.get(terminalEvent); - if (terminal === null) { - // A session release interrupts or fails the stream; a clean - // end without a terminal means the run would never settle. - return yield* new RunExecutionIngestError({ + } + if ( + event.type === "turn_item.updated" && + event.turnItem.providerTurnId === (yield* Ref.get(eventRouting)).rootProviderTurnId + ) { + yield* Ref.update(latestTurnItemOrdinal, (current) => + Math.max(current, event.turnItem.ordinal), + ); + } + if (event.type === "turn.terminal") { + yield* Ref.set(terminalEvent, event); + yield* Ref.set(rootTerminalSeen, true); + yield* finalizeRootRun(event); + } + yield* trackChildLifecycle(event, deliveredEvent !== null); + }), + ), + Stream.takeUntilEffect(() => shouldStopProviderEventIngestion), + Stream.runDrain, + Effect.mapError((cause) => new RunExecutionIngestError({ runId: input.run.id, cause })), + Effect.flatMap(() => + Effect.gen(function* () { + const terminal = yield* Ref.get(terminalEvent); + if (terminal === null) { + // A session release interrupts or fails the stream; a clean + // end without a terminal means the run would never settle. + return yield* new RunExecutionIngestError({ + runId: input.run.id, + cause: new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: input.providerThread.driver, + providerSessionId: input.providerSessionId, + cause: "Provider event stream ended without a root terminal.", + }), + }); + } + yield* finalizeRootRun(terminal); + }), + ), + // Server shutdown interrupts the subscription and leaves the run to + // restart recovery; every other failure fails the run. + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Ref.get(rootRunFinalized).pipe( + Effect.flatMap((finalized) => + Effect.logWarning("orchestration V2 provider event ingestion failed", { runId: input.run.id, - cause: new ProviderAdapter.ProviderAdapterEventStreamError({ - driver: input.providerThread.driver, - providerSessionId: input.providerSessionId, - cause: "Provider event stream ended without a root terminal.", - }), - }); - } - yield* finalizeRootRun(terminal); - }), - ), - // Server shutdown interrupts the subscription and leaves the run to - // restart recovery; every other failure fails the run. - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => - Ref.get(rootRunFinalized).pipe( - Effect.flatMap((finalized) => - Effect.logWarning("orchestration V2 provider event ingestion failed", { - runId: input.run.id, - cause, - }).pipe( - Effect.andThen( - finalized - ? Effect.void - : Ref.get(latestProviderThread).pipe( - Effect.flatMap((providerThread) => - Ref.get(latestTurnItemOrdinal).pipe( - Effect.flatMap((latestItemOrdinal) => - Ref.get(openRunOwnedSubagents).pipe( - Effect.flatMap((openSubagents) => - writeFinalRunEvents({ - run: input.run, - rootNode: input.rootNode, - checkpointScope: input.checkpointScope, - providerThread, - attempt: input.attempt, - writeIfRunCurrent: { - activeAttemptId: input.attempt.id, - expectedStatus: "running", - }, - openRunOwnedSubagents: openSubagents, - terminal: makeFailedTerminalEvent( - makeProviderFailure({ - cause: Cause.squash(cause), - class: "unknown", - }), - latestItemOrdinal + 1, - ), - failureItemPersisted: false, - refreshAfterTurn, - }), - ), + cause, + }).pipe( + Effect.andThen( + finalized + ? Effect.void + : Ref.get(latestProviderThread).pipe( + Effect.flatMap((providerThread) => + Ref.get(latestTurnItemOrdinal).pipe( + Effect.flatMap((latestItemOrdinal) => + Ref.get(openRunOwnedSubagents).pipe( + Effect.flatMap((openSubagents) => + writeFinalRunEvents({ + run: input.run, + rootNode: input.rootNode, + checkpointScope: input.checkpointScope, + providerThread, + attempt: input.attempt, + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running", + }, + openRunOwnedSubagents: openSubagents, + terminal: makeFailedTerminalEvent( + makeProviderFailure({ + cause: Cause.squash(cause), + class: "unknown", + }), + latestItemOrdinal + 1, + ), + failureItemPersisted: false, + refreshAfterTurn, + }), ), ), ), ), ), - ), - Effect.mapError( - (writeCause) => - new RunExecutionIngestError({ - runId: input.run.id, - cause: { ingest: cause, write: writeCause }, - }), - ), + ), + ), + Effect.mapError( + (writeCause) => + new RunExecutionIngestError({ + runId: input.run.id, + cause: { ingest: cause, write: writeCause }, + }), ), ), ), - ), - Effect.ensuring(eventSubscription.close), - Effect.forkDetach, - ); + ), + ), + Effect.ensuring(eventSubscription.close), + Effect.forkDetach, + ); - // A provider turn is a sign that the session is still alive. Keep - // its already-issued MCP credential valid even when the agent goes - // a long time between browser-tool calls. - yield* McpSessionRegistry.touchActiveMcpThread(input.run.threadId); - const appContext = (yield* mcpAppModelContext - .forThread(input.run.threadId) - .pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to read MCP app model context.", { cause }).pipe( - Effect.as([]), - ), + // A provider turn is a sign that the session is still alive. Keep + // its already-issued MCP credential valid even when the agent goes + // a long time between browser-tool calls. + yield* McpSessionRegistry.touchActiveMcpThread(input.run.threadId); + const appContext = (yield* mcpAppModelContext + .forThread(input.run.threadId) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read MCP app model context.", { cause }).pipe( + Effect.as([]), ), - )).map((entry) => ({ - // The item id alone is unique and needs no escaping; server and - // tool names are free text that would break the tag Codex wraps - // the context in. - key: `mcp_app_${entry.itemId.replace(/[^\w.-]/g, "_")}`, - text: entry.text, - })); - const turnInput = { - appThread: input.appThread, - threadId: input.run.threadId, - runId: input.run.id, - runOrdinal: input.run.ordinal, - providerTurnOrdinal: input.providerTurnOrdinal, - ...(input.nativeThreadHasTurns === undefined - ? {} - : { nativeThreadHasTurns: input.nativeThreadHasTurns }), - ...(input.run.restartContinuationOfRunId === undefined - ? {} - : { - restartContinuationOfRunId: input.run.restartContinuationOfRunId, - }), - attemptId: input.attemptId, - rootNodeId: input.rootNode.id, - providerThread: input.providerThread, - message: input.message, - modelSelection: input.modelSelection, - runtimePolicy: input.runtimePolicy, - ...(appContext.length === 0 ? {} : { appContext }), - }; - // Check after the context read: ingestion can settle the run while - // it awaits. A failed ownership read fails the start below. - const shouldStart = - input.shouldStartProviderTurn === undefined - ? Exit.succeed(true) - : yield* Effect.exit(input.shouldStartProviderTurn()); - if (Exit.isSuccess(shouldStart) && !shouldStart.value) { - yield* Fiber.interrupt(providerEventFiber); - return; - } + ), + )).map((entry) => ({ + // The item id alone is unique and needs no escaping; server and + // tool names are free text that would break the tag Codex wraps + // the context in. + key: `mcp_app_${entry.itemId.replace(/[^\w.-]/g, "_")}`, + text: entry.text, + })); + const turnInput = { + appThread: input.appThread, + threadId: input.run.threadId, + runId: input.run.id, + runOrdinal: input.run.ordinal, + providerTurnOrdinal: input.providerTurnOrdinal, + ...(input.nativeThreadHasTurns === undefined + ? {} + : { nativeThreadHasTurns: input.nativeThreadHasTurns }), + ...(input.run.restartContinuationOfRunId === undefined + ? {} + : { + restartContinuationOfRunId: input.run.restartContinuationOfRunId, + }), + attemptId: input.attemptId, + rootNodeId: input.rootNode.id, + providerThread: input.providerThread, + message: input.message, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + ...(appContext.length === 0 ? {} : { appContext }), + }; + // Check after the context read: ingestion can settle the run while + // it awaits. A failed ownership read fails the start below. + const shouldStart = + input.shouldStartProviderTurn === undefined + ? Exit.succeed(true) + : yield* Effect.exit(input.shouldStartProviderTurn()); + if (Exit.isSuccess(shouldStart) && !shouldStart.value) { + yield* Fiber.interrupt(providerEventFiber); + return; + } - const compact = - input.message.attachments.length === 0 && - input.message.text.trim().toLowerCase() === "/compact"; - const startTurn = compact - ? (input.session.compactThread?.(turnInput) ?? - Effect.fail( - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: input.session.driver, - threadId: input.run.threadId, - providerThreadId: input.providerThread.id, - runId: input.run.id, - cause: "This provider does not support context compaction.", - }), - )) - : input.session.startTurn(turnInput); - yield* Effect.andThen(shouldStart, startTurn).pipe( - Effect.catchCause((cause) => - Effect.logError("orchestration V2 provider turn start failed", { + const compact = + input.message.attachments.length === 0 && + input.message.text.trim().toLowerCase() === "/compact"; + const startTurn = compact + ? (input.session.compactThread?.(turnInput) ?? + Effect.fail( + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: input.session.driver, + threadId: input.run.threadId, + providerThreadId: input.providerThread.id, runId: input.run.id, - cause, - }).pipe( - Effect.andThen(Fiber.interrupt(providerEventFiber)), - Effect.andThen(Ref.get(latestProviderThread)), - Effect.flatMap((providerThread) => - Ref.get(latestTurnItemOrdinal).pipe( - Effect.flatMap((latestItemOrdinal) => - Ref.get(openRunOwnedSubagents).pipe( - Effect.flatMap((openSubagents) => - writeFinalRunEvents({ - run: input.run, - rootNode: input.rootNode, - checkpointScope: input.checkpointScope, - providerThread, - attempt: input.attempt, - writeIfRunCurrent: { - activeAttemptId: input.attempt.id, - expectedStatus: "running", - }, - openRunOwnedSubagents: openSubagents, - terminal: makeFailedTerminalEvent( - makeProviderFailure({ - cause: Cause.squash(cause), - class: Exit.isFailure(shouldStart) ? "unknown" : "provider_error", - }), - latestItemOrdinal + 1, - ), - failureItemPersisted: false, - refreshAfterTurn, - }), - ), + cause: "This provider does not support context compaction.", + }), + )) + : input.session.startTurn(turnInput); + yield* Effect.andThen(shouldStart, startTurn).pipe( + Effect.catchCause((cause) => + Effect.logError("orchestration V2 provider turn start failed", { + runId: input.run.id, + cause, + }).pipe( + Effect.andThen(Fiber.interrupt(providerEventFiber)), + Effect.andThen(Ref.get(latestProviderThread)), + Effect.flatMap((providerThread) => + Ref.get(latestTurnItemOrdinal).pipe( + Effect.flatMap((latestItemOrdinal) => + Ref.get(openRunOwnedSubagents).pipe( + Effect.flatMap((openSubagents) => + writeFinalRunEvents({ + run: input.run, + rootNode: input.rootNode, + checkpointScope: input.checkpointScope, + providerThread, + attempt: input.attempt, + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running", + }, + openRunOwnedSubagents: openSubagents, + terminal: makeFailedTerminalEvent( + makeProviderFailure({ + cause: Cause.squash(cause), + class: Exit.isFailure(shouldStart) ? "unknown" : "provider_error", + }), + latestItemOrdinal + 1, + ), + failureItemPersisted: false, + refreshAfterTurn, + }), ), ), ), ), - Effect.mapError( - (writeCause) => - new RunExecutionStartError({ - commandId: input.commandId, - runId: input.run.id, - cause: { start: cause, write: writeCause }, - }), - ), + ), + Effect.mapError( + (writeCause) => + new RunExecutionStartError({ + commandId: input.commandId, + runId: input.run.id, + cause: { start: cause, write: writeCause }, + }), ), ), - ); - }), + ), + ); + }), } satisfies RunExecutionServiceV2Shape); }), ); diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index b1136f6f8b04..40882033ba52 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -98,8 +98,8 @@ export const layerObserver = Layer.effect( const pullRequests = yield* PullRequestService.PullRequestService; return { refreshAfterTurn: pullRequests.refreshAfterTurn, - refresh: ({ cwd, threadId, runId }) => - Effect.gen(function* () { + refresh: Effect.fn("RunFinalizationService.refresh")( + function* ({ cwd, threadId, runId }) { const [, local] = yield* Effect.all( [workspaceEntries.refresh(cwd), vcsStatus.refreshLocalStatus(cwd)], { concurrency: "unbounded" }, @@ -117,7 +117,10 @@ export const layerObserver = Layer.effect( }), ), ); - }).pipe(Effect.mapError((cause) => new RunFinalizationRefreshError({ cwd, cause }))), + }, + (effect, { cwd }) => + effect.pipe(Effect.mapError((cause) => new RunFinalizationRefreshError({ cwd, cause }))), + ), }; }), ); diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.ts index 619768532711..9c96ea5d6b40 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.ts @@ -74,8 +74,8 @@ export const layer: Layer.Layer< const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; return RuntimeRequestServiceV2.of({ - respond: (input) => - Effect.gen(function* () { + respond: Effect.fn("RuntimeRequestService.respond")( + function* (input) { const request = yield* projections.getRuntimeRequest(input.threadId, input.requestId); if (request === undefined) { return yield* new RuntimeRequestResponseExecutionError({ @@ -120,19 +120,22 @@ export const layer: Layer.Layer< ...(input.decision === undefined ? {} : { decision: input.decision }), ...(input.answers === undefined ? {} : { answers: input.answers }), }); - }).pipe( - Effect.mapError((cause) => - isRuntimeRequestResponseExecutionError(cause) - ? cause - : new RuntimeRequestResponseExecutionError({ - reason: "unexpected-failure", - threadId: input.threadId, - providerSessionId: input.providerSessionId, - requestId: input.requestId, - cause, - }), + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isRuntimeRequestResponseExecutionError(cause) + ? cause + : new RuntimeRequestResponseExecutionError({ + reason: "unexpected-failure", + threadId: input.threadId, + providerSessionId: input.providerSessionId, + requestId: input.requestId, + cause, + }), + ), ), - ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index b652ea1b14be..b3b798d57dee 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -72,68 +72,67 @@ export class ThreadForkServiceV2 extends Context.Service< export const layer: Layer.Layer = Layer.succeed( ThreadForkServiceV2, ThreadForkServiceV2.of({ - plan: (input) => - Effect.gen(function* () { - if (!isForkableSourceRunStatus(input.sourceRun.status)) { - return yield* new ThreadForkPlanError({ - sourceThreadId: input.sourceProjection.thread.id, - targetThreadId: input.targetThreadId, - cause: forkableSourceRunStatusError(input.sourceRun), - }); - } - const targetThread: OrchestrationV2AppThread = { - ...input.sourceProjection.thread, - // PR context follows the conversation; watching remains the source thread's responsibility. - pullRequests: input.sourceProjection.thread.pullRequests?.map( - ({ watch: _watch, ...link }) => link, - ), - createdBy: input.createdBy, - creationSource: input.creationSource, - id: input.targetThreadId, - title: input.title ?? `${input.sourceProjection.thread.title} fork`, - activeProviderThreadId: null, - lineage: { - parentThreadId: input.sourceProjection.thread.id, - relationshipToParent: "fork", - rootThreadId: input.sourceProjection.thread.lineage.rootThreadId, - }, - forkedFrom: { - type: "run", - threadId: input.sourceProjection.thread.id, - runId: input.sourceRun.id, - }, - createdAt: input.createdAt, - updatedAt: input.createdAt, - archivedAt: null, - settledOverride: null, - settledAt: null, - snoozedUntil: null, - snoozedAt: null, - lastVisitedAt: null, - deletedAt: null, - }; - const transfer: OrchestrationV2ContextTransfer = { - id: input.transferId, - type: "fork", + plan: Effect.fn("ThreadForkService.plan")(function* (input) { + if (!isForkableSourceRunStatus(input.sourceRun.status)) { + return yield* new ThreadForkPlanError({ sourceThreadId: input.sourceProjection.thread.id, targetThreadId: input.targetThreadId, - sourcePoint: input.canonicalSourcePoint, - basePoint: null, - sourceProviderInstanceId: input.sourceRun.providerInstanceId, - targetProviderInstanceId: null, - targetRunId: null, - status: "pending", - resolution: null, - createdBy: input.createdBy, - error: - input.sourceProviderThread?.nativeThreadRef?.strength === "strong" - ? null - : "Source provider thread does not expose a strong native thread ref.", - createdAt: input.createdAt, - updatedAt: input.createdAt, - consumedAt: null, - }; - return { targetThread, transfer }; - }), + cause: forkableSourceRunStatusError(input.sourceRun), + }); + } + const targetThread: OrchestrationV2AppThread = { + ...input.sourceProjection.thread, + // PR context follows the conversation; watching remains the source thread's responsibility. + pullRequests: input.sourceProjection.thread.pullRequests?.map( + ({ watch: _watch, ...link }) => link, + ), + createdBy: input.createdBy, + creationSource: input.creationSource, + id: input.targetThreadId, + title: input.title ?? `${input.sourceProjection.thread.title} fork`, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.sourceProjection.thread.id, + relationshipToParent: "fork", + rootThreadId: input.sourceProjection.thread.lineage.rootThreadId, + }, + forkedFrom: { + type: "run", + threadId: input.sourceProjection.thread.id, + runId: input.sourceRun.id, + }, + createdAt: input.createdAt, + updatedAt: input.createdAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + const transfer: OrchestrationV2ContextTransfer = { + id: input.transferId, + type: "fork", + sourceThreadId: input.sourceProjection.thread.id, + targetThreadId: input.targetThreadId, + sourcePoint: input.canonicalSourcePoint, + basePoint: null, + sourceProviderInstanceId: input.sourceRun.providerInstanceId, + targetProviderInstanceId: null, + targetRunId: null, + status: "pending", + resolution: null, + createdBy: input.createdBy, + error: + input.sourceProviderThread?.nativeThreadRef?.strength === "strong" + ? null + : "Source provider thread does not expose a strong native thread ref.", + createdAt: input.createdAt, + updatedAt: input.createdAt, + consumedAt: null, + }; + return { targetThread, transfer }; + }), }), ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 4390bc48d1f3..f1c3d432a10b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -330,8 +330,8 @@ const make = Effect.gen(function* () { } yield* Effect.gen(function* () { const initialMessage = input.initialMessage; - const generateBranchNameFor = (cwd: string, message: ThreadLaunchInitialMessage) => - Effect.gen(function* () { + const generateBranchNameFor = Effect.fn("ThreadLaunchService.generateBranchNameFor")( + function* (cwd: string, message: ThreadLaunchInitialMessage) { const settings = resolveProjectSettings( yield* serverSettings.getSettings, input.projectId, @@ -363,7 +363,8 @@ const make = Effect.gen(function* () { exactName: settings.branchNamingMode === "custom", })), ); - }); + }, + ); const requestedBranch = input.workspaceStrategy.branch; let branch: string | null; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index e85f33d259ad..af44e898021b 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -748,184 +748,185 @@ const make = Effect.gen(function* () { ), ); - const sendToThread: ThreadManagementServiceShape["sendToThread"] = (input) => - Effect.gen(function* () { - const target = yield* getProjectThreadRecords(input, ["runs", "providerTurns"]); - if (target.thread.archivedAt !== null) { - return yield* new ThreadManagementThreadArchivedError({ + const sendToThread: ThreadManagementServiceShape["sendToThread"] = Effect.fn( + "ThreadManagementService.sendToThread", + )(function* (input) { + const target = yield* getProjectThreadRecords(input, ["runs", "providerTurns"]); + if (target.thread.archivedAt !== null) { + return yield* new ThreadManagementThreadArchivedError({ + threadId: input.threadId, + }); + } + const steerableRun = latestSteerableRun(target); + let dispatchMode: Extract< + OrchestrationV2Command, + { readonly type: "message.dispatch" } + >["dispatchMode"]; + if (input.mode === "steer" || input.mode === "restart") { + if (steerableRun === undefined) { + return yield* new ThreadManagementNoSteerableRunError({ threadId: input.threadId, + mode: input.mode, }); } - const steerableRun = latestSteerableRun(target); - let dispatchMode: Extract< - OrchestrationV2Command, - { readonly type: "message.dispatch" } - >["dispatchMode"]; - if (input.mode === "steer" || input.mode === "restart") { - if (steerableRun === undefined) { - return yield* new ThreadManagementNoSteerableRunError({ - threadId: input.threadId, - mode: input.mode, - }); - } - dispatchMode = { - type: input.mode === "steer" ? "steer_active" : "restart_active", - targetRunId: steerableRun.id, - }; - } else if (input.mode === "auto" && steerableRun !== undefined) { - dispatchMode = { type: "steer_active", targetRunId: steerableRun.id }; - } else { - dispatchMode = { - type: input.mode === "queue" ? "queue_after_active" : "start_immediately", - }; - } + dispatchMode = { + type: input.mode === "steer" ? "steer_active" : "restart_active", + targetRunId: steerableRun.id, + }; + } else if (input.mode === "auto" && steerableRun !== undefined) { + dispatchMode = { type: "steer_active", targetRunId: steerableRun.id }; + } else { + dispatchMode = { + type: input.mode === "queue" ? "queue_after_active" : "start_immediately", + }; + } - const dispatched = yield* dispatch({ - type: "message.dispatch", - commandId: input.commandId, + const dispatched = yield* dispatch({ + type: "message.dispatch", + commandId: input.commandId, + threadId: input.threadId, + messageId: input.messageId, + ...(input.scheduledTaskId === undefined ? {} : { scheduledTaskId: input.scheduledTaskId }), + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + text: input.text, + attachments: input.attachments, + ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), + dispatchMode, + createdBy: input.createdBy, + creationSource: input.creationSource, + }); + const projection = yield* getProjectThreadRecords(input, ["runs", "messages", "turnItems"], { + messageIds: [input.messageId], + turnItemTypes: ["user_message"], + }); + const message = projection.messages.find((candidate) => candidate.id === input.messageId); + const run = + message?.runId === null || message?.runId === undefined + ? undefined + : projection.runs.find((candidate) => candidate.id === message.runId); + const turnItem = + projection.turnItems.find( + ( + candidate, + ): candidate is Extract => + candidate.type === "user_message" && candidate.messageId === input.messageId, + ) ?? null; + if ( + message === undefined || + run === undefined || + (turnItem === null && run.status !== "queued") + ) { + return yield* new ThreadManagementDurableRunProjectionError({ threadId: input.threadId, messageId: input.messageId, - ...(input.scheduledTaskId === undefined ? {} : { scheduledTaskId: input.scheduledTaskId }), - ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), - text: input.text, - attachments: input.attachments, - ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), - dispatchMode, - createdBy: input.createdBy, - creationSource: input.creationSource, }); - const projection = yield* getProjectThreadRecords(input, ["runs", "messages", "turnItems"], { - messageIds: [input.messageId], - turnItemTypes: ["user_message"], + } + const delivery: ThreadManagementSendResult["delivery"] = + turnItem === null || turnItem.inputIntent === "queued_turn" + ? "queued" + : turnItem.inputIntent === "turn_start" + ? "started" + : input.mode === "restart" + ? "restarted" + : "steered"; + return { dispatch: dispatched, projection, message, run, turnItem, delivery }; + }); + + const waitForThread: ThreadManagementServiceShape["waitForThread"] = Effect.fn( + "ThreadManagementService.waitForThread", + )(function* (input) { + const loadError = (cause: unknown) => + new ThreadManagementProjectionLoadError({ + projectId: input.projectId, + threadId: input.threadId, + cause, + }); + // Taken before the first read, so a run update between that read and + // the subscription below still replays. + const afterSequence = yield* orchestrator + .getThreadEventSequence(input.threadId) + .pipe(Effect.mapError(loadError)); + const target = yield* getProjectThreadRecords(input, ["runs"]); + const selectedRun = + input.runId === undefined + ? latestRun(target) + : target.runs.find((candidate) => candidate.id === input.runId); + if (input.runId !== undefined && selectedRun === undefined) { + return yield* new ThreadManagementRunNotFoundError({ + threadId: input.threadId, + runId: input.runId, }); - const message = projection.messages.find((candidate) => candidate.id === input.messageId); - const run = - message?.runId === null || message?.runId === undefined - ? undefined - : projection.runs.find((candidate) => candidate.id === message.runId); - const turnItem = - projection.turnItems.find( - ( - candidate, - ): candidate is Extract => - candidate.type === "user_message" && candidate.messageId === input.messageId, - ) ?? null; - if ( - message === undefined || - run === undefined || - (turnItem === null && run.status !== "queued") - ) { - return yield* new ThreadManagementDurableRunProjectionError({ + } + if (selectedRun === undefined) { + return { threadId: input.threadId, run: null, timedOut: false }; + } + // A restart cuts a run and continues it as a new run. Follow that chain, + // so a cut run is not the answer while its continuation is pending. + // Runs per matching event and is already covered by the waitForThread span. + const outcome = Effect.fnUntraced(function* (runs: ReadonlyArray) { + const current = runs.find((candidate) => candidate.id === selectedRun.id); + if (current === undefined) { + return yield* new ThreadManagementRunNotFoundError({ threadId: input.threadId, - messageId: input.messageId, + runId: selectedRun.id, }); } - const delivery: ThreadManagementSendResult["delivery"] = - turnItem === null || turnItem.inputIntent === "queued_turn" - ? "queued" - : turnItem.inputIntent === "turn_start" - ? "started" - : input.mode === "restart" - ? "restarted" - : "steered"; - return { dispatch: dispatched, projection, message, run, turnItem, delivery }; + const run = latestRestartContinuation(runs, current); + const pending = + isTerminalRunStatus(run.status) && (yield* orchestrator.restartContinuationPending(run)); + return { run, pending, settled: isTerminalRunStatus(run.status) && !pending }; }); + const initial = yield* outcome(target.runs); + if (initial.settled) { + return { threadId: input.threadId, run: initial.run, timedOut: false }; + } - const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) => - Effect.gen(function* () { - const loadError = (cause: unknown) => - new ThreadManagementProjectionLoadError({ - projectId: input.projectId, + // A declined continuation records no event, so its effect settling wakes + // the wait too. + const wait = Stream.merge( + Stream.merge( + orchestrator.streamStoredEventsFrom({ threadId: input.threadId, - cause, - }); - // Taken before the first read, so a run update between that read and - // the subscription below still replays. - const afterSequence = yield* orchestrator - .getThreadEventSequence(input.threadId) - .pipe(Effect.mapError(loadError)); - const target = yield* getProjectThreadRecords(input, ["runs"]); - const selectedRun = - input.runId === undefined - ? latestRun(target) - : target.runs.find((candidate) => candidate.id === input.runId); - if (input.runId !== undefined && selectedRun === undefined) { - return yield* new ThreadManagementRunNotFoundError({ + afterSequence, + eventType: "run.updated", + }), + orchestrator.streamStoredEventsFrom({ threadId: input.threadId, - runId: input.runId, - }); - } - if (selectedRun === undefined) { - return { threadId: input.threadId, run: null, timedOut: false }; - } - // A restart cuts a run and continues it as a new run. Follow that chain, - // so a cut run is not the answer while its continuation is pending. - const outcome = (runs: ReadonlyArray) => - Effect.gen(function* () { - const current = runs.find((candidate) => candidate.id === selectedRun.id); - if (current === undefined) { - return yield* new ThreadManagementRunNotFoundError({ - threadId: input.threadId, - runId: selectedRun.id, - }); - } - const run = latestRestartContinuation(runs, current); - const pending = - isTerminalRunStatus(run.status) && - (yield* orchestrator.restartContinuationPending(run)); - return { run, pending, settled: isTerminalRunStatus(run.status) && !pending }; - }); - const initial = yield* outcome(target.runs); - if (initial.settled) { - return { threadId: input.threadId, run: initial.run, timedOut: false }; - } - - // A declined continuation records no event, so its effect settling wakes - // the wait too. - const wait = Stream.merge( - Stream.merge( - orchestrator.streamStoredEventsFrom({ - threadId: input.threadId, - afterSequence, - eventType: "run.updated", - }), - orchestrator.streamStoredEventsFrom({ - threadId: input.threadId, - afterSequence, - eventType: "thread.deleted", - }), - ).pipe( - Stream.mapError(loadError), - Stream.filter( - (stored) => - stored.event.type !== "run.updated" || - stored.event.payload.id === selectedRun.id || - stored.event.payload.restartContinuationOfRunId !== undefined, - ), - ), - initial.pending - ? Stream.fromEffect(orchestrator.awaitRestartContinuation(initial.run)) - : Stream.empty, + afterSequence, + eventType: "thread.deleted", + }), ).pipe( - Stream.mapEffect(() => - getProjectThreadRecords(input, ["runs"]).pipe( - Effect.flatMap((current) => outcome(current.runs)), - ), + Stream.mapError(loadError), + Stream.filter( + (stored) => + stored.event.type !== "run.updated" || + stored.event.payload.id === selectedRun.id || + stored.event.payload.restartContinuationOfRunId !== undefined, ), - Stream.filter((current) => current.settled), - Stream.runHead, - Effect.timeoutOption(Duration.millis(Math.max(1, input.timeoutMs))), - ); - const waited = Option.flatten(yield* wait); - if (Option.isSome(waited)) { - return { threadId: input.threadId, run: waited.value.run, timedOut: false }; - } - // The run may have reached a terminal status while the timeout was - // winning the race; the final projection read decides what actually - // happened, so only a still-active run counts as timed out. - const final = yield* outcome((yield* getProjectThreadRecords(input, ["runs"])).runs); - return { threadId: input.threadId, run: final.run, timedOut: !final.settled }; - }); + ), + initial.pending + ? Stream.fromEffect(orchestrator.awaitRestartContinuation(initial.run)) + : Stream.empty, + ).pipe( + Stream.mapEffect(() => + getProjectThreadRecords(input, ["runs"]).pipe( + Effect.flatMap((current) => outcome(current.runs)), + ), + ), + Stream.filter((current) => current.settled), + Stream.runHead, + Effect.timeoutOption(Duration.millis(Math.max(1, input.timeoutMs))), + ); + const waited = Option.flatten(yield* wait); + if (Option.isSome(waited)) { + return { threadId: input.threadId, run: waited.value.run, timedOut: false }; + } + // The run may have reached a terminal status while the timeout was + // winning the race; the final projection read decides what actually + // happened, so only a still-active run counts as timed out. + const final = yield* outcome((yield* getProjectThreadRecords(input, ["runs"])).runs); + return { threadId: input.threadId, run: final.run, timedOut: !final.settled }; + }); const settleAfterRun: ThreadManagementServiceShape["settleAfterRun"] = Effect.fn( "orchestrationV2.threadManagement.settleAfterRun", @@ -959,55 +960,58 @@ const make = Effect.gen(function* () { return { sequence: result.sequence }; }); - const interruptThread: ThreadManagementServiceShape["interruptThread"] = (input) => - Effect.gen(function* () { - const target = yield* getProjectThreadRecords(input, ["runs", "providerTurns"]); - const explicitRun = - input.runId === undefined - ? undefined - : target.runs.find((candidate) => candidate.id === input.runId); - if (input.runId !== undefined && explicitRun === undefined) { - return yield* new ThreadManagementRunNotFoundError({ - threadId: input.threadId, - runId: input.runId, - }); - } - if (explicitRun !== undefined && isTerminalRunStatus(explicitRun.status)) { - return { - type: "already_terminal", - run: explicitRun as OrchestrationV2Run & { - readonly status: ThreadManagementTerminalRunStatus; - }, - } as const; - } - const interruptibleRun = latestActiveRun(target); - if (interruptibleRun === undefined) { - if (input.runId === undefined) { - return { type: "no_active_run" } as const; - } - return yield* new ThreadManagementThreadNotInterruptibleError({ - threadId: input.threadId, - runId: input.runId, - }); - } - if (input.runId !== undefined && interruptibleRun.id !== input.runId) { - return yield* new ThreadManagementThreadNotInterruptibleError({ - threadId: input.threadId, - runId: input.runId, - }); + const interruptThread: ThreadManagementServiceShape["interruptThread"] = Effect.fn( + "ThreadManagementService.interruptThread", + )(function* (input) { + const target = yield* getProjectThreadRecords(input, ["runs", "providerTurns"]); + const explicitRun = + input.runId === undefined + ? undefined + : target.runs.find((candidate) => candidate.id === input.runId); + if (input.runId !== undefined && explicitRun === undefined) { + return yield* new ThreadManagementRunNotFoundError({ + threadId: input.threadId, + runId: input.runId, + }); + } + if (explicitRun !== undefined && isTerminalRunStatus(explicitRun.status)) { + return { + type: "already_terminal", + run: explicitRun as OrchestrationV2Run & { + readonly status: ThreadManagementTerminalRunStatus; + }, + } as const; + } + const interruptibleRun = latestActiveRun(target); + if (interruptibleRun === undefined) { + if (input.runId === undefined) { + return { type: "no_active_run" } as const; } - const dispatch = yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: input.commandId, + return yield* new ThreadManagementThreadNotInterruptibleError({ threadId: input.threadId, - runId: interruptibleRun.id, - ...(input.reason === undefined ? {} : { reason: input.reason }), + runId: input.runId, + }); + } + if (input.runId !== undefined && interruptibleRun.id !== input.runId) { + return yield* new ThreadManagementThreadNotInterruptibleError({ + threadId: input.threadId, + runId: input.runId, }); - return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; + } + const dispatch = yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: input.commandId, + threadId: input.threadId, + runId: interruptibleRun.id, + ...(input.reason === undefined ? {} : { reason: input.reason }), }); + return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; + }); - const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = (input) => - Effect.gen(function* () { + const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = Effect.fn( + "ThreadManagementService.stopWorkflow", + )( + function* (input) { const records = yield* orchestrator .getThreadRecords(input.threadId, ["subagents", "runs", "providerThreads"]) .pipe( @@ -1088,39 +1092,42 @@ const make = Effect.gen(function* () { }), ), ); - }).pipe((effect) => threadDispatch.withLock(input.threadId, effect)); + }, + (effect, input) => effect.pipe((effect) => threadDispatch.withLock(input.threadId, effect)), + ); - const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) => - Effect.gen(function* () { - const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); - const failures: Array< - Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError - > = []; - const stoppedChildThreadIds = new Set(); - for (const task of subagents) { - if (task.origin !== "app_owned" || task.childThreadId === null) continue; - const threadId = task.childThreadId; - // Follow-up tasks reuse a child thread; walk its subtree only once. - if (stoppedChildThreadIds.has(threadId)) continue; - stoppedChildThreadIds.add(threadId); - yield* dispatch({ - type: "thread.stop", - commandId: CommandId.make(`${input.commandId}:stop:${threadId}`), - threadId, - ...(input.reason === undefined ? {} : { reason: input.reason }), - }).pipe( - Effect.andThen(stopDelegatedTasks({ ...input, threadId })), - Effect.catch((error) => - Effect.logWarning("Unable to stop a delegated task", { - parentThreadId: input.threadId, - threadId, - error, - }).pipe(Effect.andThen(Effect.sync(() => failures.push(error)))), - ), - ); - } - if (failures[0] !== undefined) return yield* Effect.fail(failures[0]); - }); + const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = Effect.fn( + "ThreadManagementService.stopDelegatedTasks", + )(function* (input) { + const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); + const failures: Array< + Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError + > = []; + const stoppedChildThreadIds = new Set(); + for (const task of subagents) { + if (task.origin !== "app_owned" || task.childThreadId === null) continue; + const threadId = task.childThreadId; + // Follow-up tasks reuse a child thread; walk its subtree only once. + if (stoppedChildThreadIds.has(threadId)) continue; + stoppedChildThreadIds.add(threadId); + yield* dispatch({ + type: "thread.stop", + commandId: CommandId.make(`${input.commandId}:stop:${threadId}`), + threadId, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }).pipe( + Effect.andThen(stopDelegatedTasks({ ...input, threadId })), + Effect.catch((error) => + Effect.logWarning("Unable to stop a delegated task", { + parentThreadId: input.threadId, + threadId, + error, + }).pipe(Effect.andThen(Effect.sync(() => failures.push(error)))), + ), + ); + } + if (failures[0] !== undefined) return yield* Effect.fail(failures[0]); + }); return ThreadManagementService.of({ ensureLegacyTranscript, diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index c07eca4048bb..9f5b3ab38c9b 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -361,9 +361,10 @@ const make = Effect.gen(function* () { ORDER BY created_at ASC, message_id ASC `; - const listShellMessages = (threadId: ThreadId) => - Effect.gen(function* () { - const latest = yield* sql` + const listShellMessages = Effect.fn("LegacyV1ThreadImporter.listShellMessages")(function* ( + threadId: ThreadId, + ) { + const latest = yield* sql` SELECT message.message_id, message.thread_id, @@ -393,7 +394,7 @@ const make = Effect.gen(function* () { ORDER BY message.created_at DESC, message.message_id DESC LIMIT 1 `; - const latestUser = yield* sql` + const latestUser = yield* sql` SELECT message.message_id, message.thread_id, @@ -423,12 +424,12 @@ const make = Effect.gen(function* () { ORDER BY message.created_at DESC, message.message_id DESC LIMIT 1 `; - return [latestUser[0], latest[0]].filter( - (message, index, selected): message is LegacyMessageRow => - message !== undefined && - selected.findIndex((candidate) => candidate?.message_id === message.message_id) === index, - ); - }); + return [latestUser[0], latest[0]].filter( + (message, index, selected): message is LegacyMessageRow => + message !== undefined && + selected.findIndex((candidate) => candidate?.message_id === message.message_id) === index, + ); + }); const reconcileShellsBase = Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); From 221d8494c75fc619c36b117ffc423846733ef353 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:31:00 -0700 Subject: [PATCH 008/458] refactor(server): trace reusable domain operations (#21996) --- .../server/src/background/BackgroundPolicy.ts | 21 +- .../src/desktopUpdate/DesktopAppUpdate.ts | 115 ++- apps/server/src/device/DeviceActions.ts | 74 +- apps/server/src/device/DeviceHubProxy.ts | 5 +- apps/server/src/device/DeviceService.ts | 239 ++--- apps/server/src/device/LocalDeviceHost.ts | 54 +- .../src/diagnostics/TraceDiagnostics.ts | 12 +- apps/server/src/git/GitManager.ts | 149 +-- apps/server/src/git/detachStackFrame.ts | 12 +- apps/server/src/git/linkCreatedPullRequest.ts | 69 +- apps/server/src/htmlRender/HtmlRender.ts | 19 +- apps/server/src/htmlRender/headlessChrome.ts | 41 +- apps/server/src/keybindings.ts | 33 +- apps/server/src/launcherWatch.ts | 35 +- apps/server/src/observability/Metrics.ts | 13 +- .../src/persistence/DatabaseMaintenance.ts | 29 +- apps/server/src/process/agentScope.ts | 186 ++-- .../server/src/project/ProjectCloneTracker.ts | 149 +-- .../src/project/ProjectFaviconResolver.ts | 21 +- .../src/project/ProjectSetupScriptRunner.ts | 23 +- .../src/project/WorktreeSetupTracker.ts | 103 +- .../src/pullRequest/PullRequestReadCache.ts | 20 +- .../src/pullRequest/PullRequestService.ts | 772 +++++++------- .../src/pullRequest/pullRequestViewedFiles.ts | 26 +- .../src/relay/AgentAwarenessRelay.test.ts | 25 + apps/server/src/relay/AgentAwarenessRelay.ts | 135 +-- .../NativeTelemetryClient.ts | 180 ++-- .../resourceTelemetry/ResourceTelemetry.ts | 66 +- .../src/scheduledTasks/RelayDeliveryProof.ts | 8 +- .../scheduledTasks/ScheduledTaskService.ts | 938 +++++++++--------- .../ScheduledTaskService.webhook.test.ts | 28 + .../server/src/scheduledTasks/webhookRoute.ts | 166 ++-- apps/server/src/secrets/SecretRequests.ts | 64 +- apps/server/src/serverActivation.ts | 27 +- apps/server/src/serverRuntimeState.ts | 79 +- apps/server/src/serverSettings.ts | 123 ++- apps/server/src/storageCleanup.ts | 12 +- .../src/telemetry/DelegatedTaskAnalytics.ts | 9 +- apps/server/src/terminal/Manager.ts | 158 +-- apps/server/src/usage/UsageLimitSources.ts | 52 +- apps/server/src/usage/UsageService.ts | 95 +- apps/server/src/vcs/GitVcsDriver.ts | 19 +- apps/server/src/vcs/GitVcsDriverCore.ts | 55 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 141 ++- apps/server/src/workspace/WorkspaceEntries.ts | 17 +- 45 files changed, 2357 insertions(+), 2260 deletions(-) diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts index 49e1c7c20612..9cb7d372d4de 100644 --- a/apps/server/src/background/BackgroundPolicy.ts +++ b/apps/server/src/background/BackgroundPolicy.ts @@ -289,16 +289,17 @@ export const make = Effect.fn("background.policy.make")(function* () { const hasDemand: BackgroundPolicy["Service"]["hasDemand"] = (scope) => Effect.map(snapshot, (current) => current.activeScopeKeys.includes(scopeKey(scope))); - const shouldRunScopeWork: BackgroundPolicy["Service"]["shouldRunScopeWork"] = (scope) => - Effect.gen(function* () { - const [current, settings] = yield* Effect.all([snapshot, backgroundActivitySettings]); - if (isHostConstrained(current.hostPower, settings)) { - return false; - } - return current.leases.some((lease) => - leaseMayRunScopedWork(lease, scope, current.updatedAt, settings), - ); - }); + const shouldRunScopeWork: BackgroundPolicy["Service"]["shouldRunScopeWork"] = Effect.fn( + "BackgroundPolicy.shouldRunScopeWork", + )(function* (scope) { + const [current, settings] = yield* Effect.all([snapshot, backgroundActivitySettings]); + if (isHostConstrained(current.hostPower, settings)) { + return false; + } + return current.leases.some((lease) => + leaseMayRunScopedWork(lease, scope, current.updatedAt, settings), + ); + }); const shouldRunOpportunisticWork = Effect.map( snapshot, diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index c1c49d308ee2..2d774a34ba27 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -66,74 +66,71 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( ? new ServerSelfUpdateError({ reason }) : new ServerSelfUpdateError({ reason, cause }); - const consumeReports = ( + const consumeReports = Effect.fn("DesktopAppUpdate.consumeReports")(function* ( requestId: string, changes: Stream.Stream, reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, - ) => - Effect.gen(function* () { - const lastStage = yield* Ref.make(null); - const emitStage = ( - stage: ServerSelfUpdateProgressStage | null, - ): Effect.Effect => - stage === null - ? Effect.void - : Ref.get(lastStage).pipe( - Effect.flatMap((previous) => - previous === stage - ? Effect.void - : Ref.set(lastStage, stage).pipe(Effect.andThen(reportProgress(stage))), - ), - ); + ) { + const lastStage = yield* Ref.make(null); + const emitStage = ( + stage: ServerSelfUpdateProgressStage | null, + ): Effect.Effect => + stage === null + ? Effect.void + : Ref.get(lastStage).pipe( + Effect.flatMap((previous) => + previous === stage + ? Effect.void + : Ref.set(lastStage, stage).pipe(Effect.andThen(reportProgress(stage))), + ), + ); - const terminal = yield* changes.pipe( - Stream.filter((report) => report.requestId === requestId), - Stream.mapEffect( - ( - report, - ): Effect.Effect, ServerSelfUpdateError> => - report.outcome === undefined - ? emitStage(desktopUpdateProgressStage(report.state)).pipe( - Effect.as(Option.none()), - ) - : Effect.succeedSome(report), - ), - Stream.filterMap( - Option.match({ - onNone: () => Result.failVoid, - onSome: Result.succeed, - }), - ), - Stream.runHead, - ); - if (Option.isNone(terminal)) { - return yield* failWith("The desktop app stopped reporting its update."); - } + const terminal = yield* changes.pipe( + Stream.filter((report) => report.requestId === requestId), + Stream.mapEffect( + (report): Effect.Effect, ServerSelfUpdateError> => + report.outcome === undefined + ? emitStage(desktopUpdateProgressStage(report.state)).pipe( + Effect.as(Option.none()), + ) + : Effect.succeedSome(report), + ), + Stream.filterMap( + Option.match({ + onNone: () => Result.failVoid, + onSome: Result.succeed, + }), + ), + Stream.runHead, + ); + if (Option.isNone(terminal)) { + return yield* failWith("The desktop app stopped reporting its update."); + } - const report = terminal.value; - if (report.outcome === "ready-to-install") { - yield* emitStage("installing"); - const targetVersion = - report.state.downloadedVersion ?? - report.state.availableVersion ?? - report.state.currentVersion; - yield* Effect.logInfo("Desktop app update prepared for install.", { - targetVersion, - }); - yield* Ref.set(inFlight, false); - return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId }; - } - if (report.outcome === "up-to-date") { - return yield* failWith( - `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`, - ); - } + const report = terminal.value; + if (report.outcome === "ready-to-install") { + yield* emitStage("installing"); + const targetVersion = + report.state.downloadedVersion ?? + report.state.availableVersion ?? + report.state.currentVersion; + yield* Effect.logInfo("Desktop app update prepared for install.", { + targetVersion, + }); + yield* Ref.set(inFlight, false); + return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId }; + } + if (report.outcome === "up-to-date") { return yield* failWith( - report.reason ?? report.state.message ?? "The desktop app update failed.", + `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`, ); - }); + } + return yield* failWith( + report.reason ?? report.state.message ?? "The desktop app update failed.", + ); + }); const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")( function* (reportProgress) { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index fec54eade8e9..5d2ddca0f740 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -206,20 +206,23 @@ const simctl = (run: Runner, udid: string, args: ReadonlyArray, operatio Effect.flatMap(ok(operation)), ); -const axSettings = (ready: DeviceHostReady, udid: string, args: ReadonlyArray) => - Effect.gen(function* () { - const helper = ready.helpers.serveSimAxSettings; - if (!helper) { - return yield* new DeviceActionUnavailableError({ - operation: "accessibility", - platform: "ios", - reason: "helper_missing", - }); - } - return yield* ready - .run("xcrun", ["simctl", "spawn", udid, helper, ...args]) - .pipe(Effect.flatMap(ok("accessibility"))); - }); +const axSettings = Effect.fn("DeviceActions.axSettings")(function* ( + ready: DeviceHostReady, + udid: string, + args: ReadonlyArray, +) { + const helper = ready.helpers.serveSimAxSettings; + if (!helper) { + return yield* new DeviceActionUnavailableError({ + operation: "accessibility", + platform: "ios", + reason: "helper_missing", + }); + } + return yield* ready + .run("xcrun", ["simctl", "spawn", udid, helper, ...args]) + .pipe(Effect.flatMap(ok("accessibility"))); +}); const runIos = Effect.fn("DeviceActions.runIos")(function* ( ready: DeviceHostReady, @@ -318,31 +321,30 @@ const runIos = Effect.fn("DeviceActions.runIos")(function* ( } }); -const serveSimPermissions = ( +const serveSimPermissions = Effect.fn("DeviceActions.serveSimPermissions")(function* ( ready: DeviceHostReady, udid: string, input: Extract, -) => - Effect.gen(function* () { - const cli = ready.helpers.serveSimCli; - if (!cli) - return yield* new DeviceActionUnavailableError({ - operation: "permission", - platform: "ios", - reason: "helper_missing", - }); - yield* ready - .run(ready.nodePath, [ - cli, - "permissions", - input.decision, - input.permission, - input.appId, - "-d", - udid, - ]) - .pipe(Effect.flatMap(ok("permission"))); - }); +) { + const cli = ready.helpers.serveSimCli; + if (!cli) + return yield* new DeviceActionUnavailableError({ + operation: "permission", + platform: "ios", + reason: "helper_missing", + }); + yield* ready + .run(ready.nodePath, [ + cli, + "permissions", + input.decision, + input.permission, + input.appId, + "-d", + udid, + ]) + .pipe(Effect.flatMap(ok("permission"))); +}); const adb = (run: Runner, serial: string, args: ReadonlyArray, operation: string) => run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation))); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 3d4dc0eaafa6..7c58e6eab076 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -99,8 +99,9 @@ const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( return HttpServerResponse.empty(); }); -const pumpFrames = (source: Socket.Socket, sink: Socket.Writer) => - Effect.gen(function* () { +const pumpFrames = + /* Frame pumping runs continuously; a span per frame stream adds no diagnostic value. */ + Effect.fnUntraced(function* (source: Socket.Socket, sink: Socket.Writer) { const { pull } = yield* source.reader; while (true) { yield* sink.writeAll(yield* pull); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 4dbaa49b28e9..9f58dab8fbcf 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -236,15 +236,16 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return PubSub.publish(statePubSub, next).pipe(Effect.as({ state: next })); }).pipe(Effect.map(({ state }) => state)); - const resolveHost = (hostId: DeviceHostId | undefined) => - Effect.gen(function* () { - const id = hostId ?? LOCAL_DEVICE_HOST_ID; - const host = hosts.get(id); - if (!host) { - return yield* new DeviceHostUnavailableError({ hostId: id, reason: "Unknown host." }); - } - return host; - }); + const resolveHost = Effect.fn("DeviceService.resolveHost")(function* ( + hostId: DeviceHostId | undefined, + ) { + const id = hostId ?? LOCAL_DEVICE_HOST_ID; + const host = hosts.get(id); + if (!host) { + return yield* new DeviceHostUnavailableError({ hostId: id, reason: "Unknown host." }); + } + return host; + }); const setHostStatus = ( hostId: DeviceHostId, @@ -991,33 +992,32 @@ process.exitCode = result.error ? 1 : (result.status ?? 1);`, reason: "Agent CLI installation is unavailable in this device service.", }), ), - agentTarget: (input) => - Effect.gen(function* () { - const host = yield* resolveHost(input.hostId); - const ready = yield* agentReadinessIfSupported(input.hostId); - if (!ready) - return yield* new DeviceHostUnavailableError({ - hostId: input.hostId, - reason: - "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", - }); - const configPath = yield* lifecycleLock.withPermit( - Effect.gen(function* () { - if (hosts.get(host.id) !== host) - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - reason: "Host configuration changed. Retry the operation.", - }); - return yield* configureAgent(input.hostId, ready); - }), - ); - const session = yield* agentDeviceSession( - input.threadId, - input.hostId, - input.deviceId, - ).pipe(Effect.provideService(Crypto.Crypto, crypto)); - return ["--config", configPath, "--session", session]; - }), + agentTarget: Effect.fn("DeviceService.agentTarget")(function* (input) { + const host = yield* resolveHost(input.hostId); + const ready = yield* agentReadinessIfSupported(input.hostId); + if (!ready) + return yield* new DeviceHostUnavailableError({ + hostId: input.hostId, + reason: + "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", + }); + const configPath = yield* lifecycleLock.withPermit( + Effect.gen(function* () { + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); + return yield* configureAgent(input.hostId, ready); + }), + ); + const session = yield* agentDeviceSession( + input.threadId, + input.hostId, + input.deviceId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + return ["--config", configPath, "--session", session]; + }), state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), subscribe: PubSub.subscribe(statePubSub), configure, @@ -1072,23 +1072,23 @@ export const make = Effect.gen(function* () { agentDeviceConfigPath(config.stateDir, hostId, path).pipe( Effect.provideService(Crypto.Crypto, crypto), ); - const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => - Effect.gen(function* () { + const configureAgent = Effect.fn("DeviceService.configureAgent")( + function* (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) { const file = yield* configPath(hostId); yield* writeAgentDeviceConfig(file, ready.agentDevice); return file; - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new DeviceOperationError({ - operation: "configure agent", - reason: "settings_failed", - cause, - }), - ), - ); + }, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "configure agent", + reason: "settings_failed", + cause, + }), + ), + ); const probeContext = yield* Effect.context>>(); const localTargetContext = @@ -1132,78 +1132,79 @@ export const make = Effect.gen(function* () { const hostContext = yield* Effect.context>>(); const configured = new Map(); - const reconcile = (configuredHosts: ReadonlyArray) => - Effect.gen(function* () { - const next = yield* remoteSshDeviceHosts(configuredHosts).pipe( - Effect.provide(localTargetContext), - ); - const removed = yield* service.withLifecycleLock( - Effect.gen(function* () { - const removed: Array<{ id: string; scope: Scope.Closeable }> = []; - for (const [id, previous] of configured) { - if ( - next.some( - (host) => - host.id === id && - host.label === previous.config.label && - host.target === previous.config.target && - host.port === previous.config.port && - host.identityFile === previous.config.identityFile, - ) + const reconcile = Effect.fn("DeviceService.reconcile")(function* ( + configuredHosts: ReadonlyArray, + ) { + const next = yield* remoteSshDeviceHosts(configuredHosts).pipe( + Effect.provide(localTargetContext), + ); + const removed = yield* service.withLifecycleLock( + Effect.gen(function* () { + const removed: Array<{ id: string; scope: Scope.Closeable }> = []; + for (const [id, previous] of configured) { + if ( + next.some( + (host) => + host.id === id && + host.label === previous.config.label && + host.target === previous.config.target && + host.port === previous.config.port && + host.identityFile === previous.config.identityFile, ) - continue; - hosts.delete(id); - configured.delete(id); - removed.push({ id, scope: previous.scope }); - } - if (removed.length > 0) yield* service.refreshHosts; - return removed; - }), - ); - // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts. - yield* Effect.forEach( - removed, - ({ id, scope }) => - Effect.gen(function* () { - yield* Scope.close(scope, Exit.void); - yield* fs.remove(yield* configPath(id), { force: true }).pipe(Effect.ignore); - }), - { concurrency: 4, discard: true }, - ); - yield* service.withLifecycleLock( + ) + continue; + hosts.delete(id); + configured.delete(id); + removed.push({ id, scope: previous.scope }); + } + if (removed.length > 0) yield* service.refreshHosts; + return removed; + }), + ); + // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts. + yield* Effect.forEach( + removed, + ({ id, scope }) => Effect.gen(function* () { - let added = false; - for (const host of next) { - if (configured.has(host.id)) continue; - added = true; - const hostScope = yield* Scope.fork(scope); - const instance = yield* SshDeviceHost.make( - host, - (ready) => - configureAgent(host.id, ready).pipe( - Effect.asVoid, - Effect.mapError( - (error) => - new DeviceHost.DeviceHostError({ - hostId: host.id, - step: "configuring agent access", - cause: error, - }), - ), - ), - (status, detail) => - service - .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) }) - .pipe(Effect.asVoid), - ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext)); - hosts.set(host.id, instance); - configured.set(host.id, { config: host, scope: hostScope }); - } - // Refresh again after removed scopes closed so a late status write from an old host cannot linger. - if (added || removed.length > 0) yield* service.refreshHosts; + yield* Scope.close(scope, Exit.void); + yield* fs.remove(yield* configPath(id), { force: true }).pipe(Effect.ignore); }), - ); - }); + { concurrency: 4, discard: true }, + ); + yield* service.withLifecycleLock( + Effect.gen(function* () { + let added = false; + for (const host of next) { + if (configured.has(host.id)) continue; + added = true; + const hostScope = yield* Scope.fork(scope); + const instance = yield* SshDeviceHost.make( + host, + (ready) => + configureAgent(host.id, ready).pipe( + Effect.asVoid, + Effect.mapError( + (error) => + new DeviceHost.DeviceHostError({ + hostId: host.id, + step: "configuring agent access", + cause: error, + }), + ), + ), + (status, detail) => + service + .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) }) + .pipe(Effect.asVoid), + ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext)); + hosts.set(host.id, instance); + configured.set(host.id, { config: host, scope: hostScope }); + } + // Refresh again after removed scopes closed so a late status write from an old host cannot linger. + if (added || removed.length > 0) yield* service.refreshHosts; + }), + ); + }); const changes = yield* settings.subscribeChanges; yield* reconcile((yield* settings.getSettings).deviceHosts); yield* changes.pipe( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 7a972b6a677c..639c5cfe41a9 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -493,32 +493,34 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.ignoreCause, ); - const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect => - Effect.gen(function* () { - yield* Effect.result(hub.child.exitCode); - const running = yield* Ref.get(runningRef); - if (running?.hub.child.pid !== hub.child.pid) return; - const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis; - const delay = yield* Ref.modify(restartDelayRef, (current) => { - if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0]; - const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS); - return [current, next]; - }); - yield* Effect.logWarning("Device hub exited; restarting", { - pid: Number(hub.child.pid), - delayMs: delay, - }); - yield* Effect.sleep(Duration.millis(delay)); - yield* startLock.withPermits(1)( - Effect.gen(function* () { - const current = yield* Ref.get(runningRef); - if (current?.hub.child.pid !== hub.child.pid) return; - const replacement = yield* spawnHub(hubTool, hub.nodePath); - yield* Ref.set(runningRef, { ...current, hub: replacement }); - yield* Effect.forkDetach(superviseHub(replacement, hubTool)); - }), - ); - }).pipe( + // Waits for the hub's lifetime and re-forks on restart; a span would nest across restarts. + const superviseHub: (hub: HubProcess, hubTool: DeviceToolPaths) => Effect.Effect = + Effect.fnUntraced( + function* (hub: HubProcess, hubTool: DeviceToolPaths) { + yield* Effect.result(hub.child.exitCode); + const running = yield* Ref.get(runningRef); + if (running?.hub.child.pid !== hub.child.pid) return; + const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis; + const delay = yield* Ref.modify(restartDelayRef, (current) => { + if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0]; + const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS); + return [current, next]; + }); + yield* Effect.logWarning("Device hub exited; restarting", { + pid: Number(hub.child.pid), + delayMs: delay, + }); + yield* Effect.sleep(Duration.millis(delay)); + yield* startLock.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(runningRef); + if (current?.hub.child.pid !== hub.child.pid) return; + const replacement = yield* spawnHub(hubTool, hub.nodePath); + yield* Ref.set(runningRef, { ...current, hub: replacement }); + yield* Effect.forkDetach(superviseHub(replacement, hubTool)); + }), + ); + }, Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })), ); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 823148a29a4e..5d2e3317186a 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -469,11 +469,9 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(TraceDiagnostics, make); -export function readTraceDiagnostics( +export const readTraceDiagnostics = Effect.fn("TraceDiagnostics.readTraceDiagnostics")(function* ( options: TraceDiagnosticsOptions, -): Effect.Effect { - return Effect.gen(function* () { - const diagnostics = yield* TraceDiagnostics; - return yield* diagnostics.read(options); - }); -} +): Effect.fn.Return { + const diagnostics = yield* TraceDiagnostics; + return yield* diagnostics.read(options); +}); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index ef4a1a23b791..7de85c26d023 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -755,8 +755,8 @@ export const make = Effect.gen(function* () { return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules }); }); - const readRepositoryInstructions = (cwd: string, fileName: string) => - Effect.gen(function* () { + const readRepositoryInstructions = Effect.fn("GitManager.readRepositoryInstructions")( + function* (cwd: string, fileName: string) { const root = yield* fileSystem.realPath(cwd); const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); const relative = path.relative(root, instructionPath); @@ -768,7 +768,9 @@ export const make = Effect.gen(function* () { return ""; } return (yield* fileSystem.readFileString(instructionPath)).trim(); - }).pipe(Effect.orElseSucceed(() => "")); + }, + Effect.orElseSucceed(() => ""), + ); const readRecentCommitSubjects = (cwd: string) => gitCore @@ -787,51 +789,53 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => []), ); - const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) => - Effect.gen(function* () { - switch (settings.style.mode) { - case "conventional_commits": - return conventionalCommitsTextGenerationPolicy; - case "custom": - return customTextGenerationPolicy( - settings.style.customInstructions - ? { - commitInstructions: settings.style.customInstructions, - changeRequestInstructions: settings.style.customInstructions, - } - : {}, + const resolveStylePolicy = Effect.fn("GitManager.resolveStylePolicy")(function* ( + cwd: string, + settings: SourceControlTextGenerationSettings, + ) { + switch (settings.style.mode) { + case "conventional_commits": + return conventionalCommitsTextGenerationPolicy; + case "custom": + return customTextGenerationPolicy( + settings.style.customInstructions + ? { + commitInstructions: settings.style.customInstructions, + changeRequestInstructions: settings.style.customInstructions, + } + : {}, + ); + case "repo_conventions": { + const subjects = yield* readRecentCommitSubjects(cwd); + const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md"); + const isClaudeWriter = + settings.modelSelection.instanceId === "claudeAgent" || + (yield* providerRegistry.getProviders).some( + (provider) => + provider.instanceId === settings.modelSelection.instanceId && + provider.driver === "claudeAgent", ); - case "repo_conventions": { - const subjects = yield* readRecentCommitSubjects(cwd); - const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md"); - const isClaudeWriter = - settings.modelSelection.instanceId === "claudeAgent" || - (yield* providerRegistry.getProviders).some( - (provider) => - provider.instanceId === settings.modelSelection.instanceId && - provider.driver === "claudeAgent", - ); - const claudeInstructions = isClaudeWriter - ? yield* readRepositoryInstructions(cwd, "CLAUDE.md") - : ""; - const examples = [ - ...(subjects.length > 0 - ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] - : []), - ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), - ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), - ].join("\n\n"); - if (!examples) { - return repositoryConventionsTextGenerationPolicy; - } - return { - ...repositoryConventionsTextGenerationPolicy, - commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, - changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, - }; + const claudeInstructions = isClaudeWriter + ? yield* readRepositoryInstructions(cwd, "CLAUDE.md") + : ""; + const examples = [ + ...(subjects.length > 0 + ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] + : []), + ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), + ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), + ].join("\n\n"); + if (!examples) { + return repositoryConventionsTextGenerationPolicy; } + return { + ...repositoryConventionsTextGenerationPolicy, + commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, + changeRequestInstructions: `${repositoryConventionsTextGenerationPolicy.changeRequestInstructions}\n\n${examples}`, + }; } - }); + } + }); const randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4().pipe( Effect.mapError( @@ -2370,39 +2374,38 @@ export const make = Effect.gen(function* () { string, { readonly readAtMs: number; readonly value: string | null } >(); - const rememberPrLookupGitState = ( + const rememberPrLookupGitState = Effect.fn("GitManager.rememberPrLookupGitState")(function* ( cwd: string, key: string, fresh: boolean, read: Effect.Effect, - ) => - Effect.gen(function* () { - const entryKey = `${cwd}\u0000${key}`; - const readAtMs = yield* Clock.currentTimeMillis; - const remembered = prLookupGitStateByKey.get(entryKey); - if ( - !fresh && - remembered !== undefined && - readAtMs - remembered.readAtMs < Duration.toMillis(PR_LOOKUP_GIT_STATE_TTL) - ) { - return remembered.value; - } - // invalidateStatus bumps the epoch. A read that started before a T3 git - // action, or before a newer read finished, must not replace their state. - const epoch = prLookupEpoch(cwd); - const value = yield* read; - const latest = prLookupGitStateByKey.get(entryKey); - if (prLookupEpoch(cwd) !== epoch || (latest !== undefined && latest.readAtMs > readAtMs)) { - return value; - } - prLookupGitStateByKey.delete(entryKey); - if (prLookupGitStateByKey.size >= PR_LOOKUP_CACHE_CAPACITY) { - const oldestKey = prLookupGitStateByKey.keys().next().value; - if (oldestKey !== undefined) prLookupGitStateByKey.delete(oldestKey); - } - prLookupGitStateByKey.set(entryKey, { readAtMs, value }); + ) { + const entryKey = `${cwd}\u0000${key}`; + const readAtMs = yield* Clock.currentTimeMillis; + const remembered = prLookupGitStateByKey.get(entryKey); + if ( + !fresh && + remembered !== undefined && + readAtMs - remembered.readAtMs < Duration.toMillis(PR_LOOKUP_GIT_STATE_TTL) + ) { + return remembered.value; + } + // invalidateStatus bumps the epoch. A read that started before a T3 git + // action, or before a newer read finished, must not replace their state. + const epoch = prLookupEpoch(cwd); + const value = yield* read; + const latest = prLookupGitStateByKey.get(entryKey); + if (prLookupEpoch(cwd) !== epoch || (latest !== undefined && latest.readAtMs > readAtMs)) { return value; - }); + } + prLookupGitStateByKey.delete(entryKey); + if (prLookupGitStateByKey.size >= PR_LOOKUP_CACHE_CAPACITY) { + const oldestKey = prLookupGitStateByKey.keys().next().value; + if (oldestKey !== undefined) prLookupGitStateByKey.delete(oldestKey); + } + prLookupGitStateByKey.set(entryKey, { readAtMs, value }); + return value; + }); const forgetPrLookupGitState = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( Effect.map((cacheCwd) => { diff --git a/apps/server/src/git/detachStackFrame.ts b/apps/server/src/git/detachStackFrame.ts index a653eeedafd1..7dc1982529b0 100644 --- a/apps/server/src/git/detachStackFrame.ts +++ b/apps/server/src/git/detachStackFrame.ts @@ -12,9 +12,11 @@ const snapshotStack = ( /** * Wrap a `Cache.get` so its lookup fiber inherits a materialized stack. The lazy * caller frame would otherwise retain the caller's request snapshot for the TTL. + * Stay untraced so this helper does not introduce another lazy frame to retain. */ -export const detachStackFrame = (effect: Effect.Effect) => - Effect.gen(function* () { - const frame = snapshotStack(yield* References.CurrentStackFrame); - return yield* Effect.provideService(effect, References.CurrentStackFrame, frame); - }); +export const detachStackFrame = Effect.fnUntraced(function* ( + effect: Effect.Effect, +) { + const frame = snapshotStack(yield* References.CurrentStackFrame); + return yield* Effect.provideService(effect, References.CurrentStackFrame, frame); +}); diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index 18c17ef6f12c..2b687d0832b3 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -48,38 +48,45 @@ export function createdPullRequestKey( * Never fails: the git action already succeeded and its result is on its way to the client, so * a link that cannot be made is logged and dropped. */ -export const linkCreatedPullRequest = (input: { +export const linkCreatedPullRequest: (input: { readonly threadId: ThreadId; readonly result: Pick; readonly commandId: Effect.Effect; -}): Effect.Effect => - Effect.gen(function* () { - const engine = yield* Orchestrator.OrchestratorV2; - const projects = yield* ProjectService.ProjectService; - const thread = yield* engine - .getThreadShell(input.threadId) - .pipe(Effect.map(Option.fromNullishOr)); - if (Option.isNone(thread)) return; - const project = Option.getOrUndefined(yield* projects.getShell(thread.value.projectId)); - const key = createdPullRequestKey(input.result, project); - if (key === null) return; - const commandId = yield* input.commandId; - yield* engine - .dispatch({ - type: "thread.pull-request.link", - commandId, - threadId: input.threadId, - ...key, - source: "created", - }) - .pipe(Effect.asVoid); - }).pipe( - Effect.withSpan("linkCreatedPullRequest"), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause as Cause.Cause) - : Effect.logWarning("failed to link created pull request to thread", { - threadId: input.threadId, - }), - ), +}) => Effect.Effect = + Effect.fn("linkCreatedPullRequest")( + function* (input: { + readonly threadId: ThreadId; + readonly result: Pick; + readonly commandId: Effect.Effect; + }) { + const engine = yield* Orchestrator.OrchestratorV2; + const projects = yield* ProjectService.ProjectService; + const thread = yield* engine + .getThreadShell(input.threadId) + .pipe(Effect.map(Option.fromNullishOr)); + if (Option.isNone(thread)) return; + const project = Option.getOrUndefined(yield* projects.getShell(thread.value.projectId)); + const key = createdPullRequestKey(input.result, project); + if (key === null) return; + const commandId = yield* input.commandId; + yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId, + threadId: input.threadId, + ...key, + source: "created", + }) + .pipe(Effect.asVoid); + }, + (effect, input) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.logWarning("failed to link created pull request to thread", { + threadId: input.threadId, + }), + ), + ), ); diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index fdeebc6ae905..b6a5236a858e 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -341,8 +341,8 @@ const make = Effect.gen(function* () { ), ); - const measure = (html: string) => - Effect.gen(function* () { + const measure = Effect.fn("HtmlRender.measure")( + function* (html: string) { const executable = yield* previewBrowser.installed; if (Option.isNone(executable)) return undefined; const heights = yield* launching(executable.value, (noSandbox) => @@ -365,14 +365,13 @@ const make = Effect.gen(function* () { }), ); return heights.toSorted(([left], [right]) => left - right); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not measure an HTML render; publishing it without heights.", { - cause, - }).pipe(Effect.as(undefined)), - ), - Effect.withSpan("HtmlRender.measure"), - ); + }, + Effect.catch((cause) => + Effect.logWarning("Could not measure an HTML render; publishing it without heights.", { + cause, + }).pipe(Effect.as(undefined)), + ), + ); // The bootstrap goes in first so its head scan never runs over inlined image data. const inline = (html: string) => diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index 7c476d392d35..4ad26a4aa683 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -381,31 +381,30 @@ const launchBrowser = Effect.fnUntraced(function* (input: { Effect.forkScoped, ); - const send = ( + const send = Effect.fn("headlessChrome.send")(function* ( method: string, params: Record, result: Schema.Decoder, sessionId?: string, - ) => - Effect.gen(function* () { - if (disconnected) return yield* disconnected; - const id = ++nextId; - const reply = yield* Deferred.make(); - pending.set(id, { method, reply }); - const message = encodeCdpCommand({ id, method, params, ...(sessionId ? { sessionId } : {}) }); - yield* Queue.offer(outgoing, new TextEncoder().encode(`${message}\0`)); - return yield* Deferred.await(reply).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(result)), - Effect.mapError((error) => - error._tag === "HtmlRenderBrowserError" - ? error - : new HtmlRenderBrowserError({ - reason: `${method} returned an unexpected result`, - cause: error, - }), - ), - ); - }); + ) { + if (disconnected) return yield* disconnected; + const id = ++nextId; + const reply = yield* Deferred.make(); + pending.set(id, { method, reply }); + const message = encodeCdpCommand({ id, method, params, ...(sessionId ? { sessionId } : {}) }); + yield* Queue.offer(outgoing, new TextEncoder().encode(`${message}\0`)); + return yield* Deferred.await(reply).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(result)), + Effect.mapError((error) => + error._tag === "HtmlRenderBrowserError" + ? error + : new HtmlRenderBrowserError({ + reason: `${method} returned an unexpected result`, + cause: error, + }), + ), + ); + }); const openPage = Effect.fnUntraced(function* (width: number) { const { targetId } = yield* send( diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 177da6652234..0dea8553791b 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -66,24 +66,23 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( Effect.map((resolved) => resolved), ), - encode: (resolved) => - Effect.gen(function* () { - const key = encodeShortcut(resolved.shortcut); - if (!key) { - return yield* Effect.fail( - new SchemaIssue.InvalidValue({ - message: "Resolved shortcut cannot be encoded to key string", - }), - ); - } + encode: Effect.fn("keybindings.encode")(function* (resolved) { + const key = encodeShortcut(resolved.shortcut); + if (!key) { + return yield* Effect.fail( + new SchemaIssue.InvalidValue({ + message: "Resolved shortcut cannot be encoded to key string", + }), + ); + } - const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined; - return { - key, - command: resolved.command, - when, - }; - }), + const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined; + return { + key, + command: resolved.command, + when, + }; + }), }), ), ); diff --git a/apps/server/src/launcherWatch.ts b/apps/server/src/launcherWatch.ts index 4e1c5a832121..d88702802474 100644 --- a/apps/server/src/launcherWatch.ts +++ b/apps/server/src/launcherWatch.ts @@ -21,25 +21,24 @@ const startupParentPid = process.ppid; * lose, and Windows keeps the original parent id after the parent exits, so * neither is watched. */ -export const awaitLauncherLoss = (options?: { +export const awaitLauncherLoss = Effect.fn("launcherWatch.awaitLauncherLoss")(function* (options?: { readonly launcherPid?: number; readonly getParentPid?: () => number; readonly interval?: Duration.Input; -}): Effect.Effect => - Effect.gen(function* () { - const platform = yield* HostProcess.Platform; - const launcherPid = options?.launcherPid ?? startupParentPid; - const getParentPid = options?.getParentPid ?? (() => process.ppid); - const interval = options?.interval ?? DEFAULT_POLL_INTERVAL; - if (platform === "win32" || launcherPid <= 1) return yield* Effect.never; - const poll: Effect.Effect = Effect.suspend(() => { - const parentPid = getParentPid(); - return parentPid === launcherPid - ? Effect.flatMap(Effect.sleep(interval), () => poll) - : Effect.logWarning("desktop launcher is gone; shutting down the backend", { - launcherPid, - parentPid, - }); - }); - return yield* poll; +}): Effect.fn.Return { + const platform = yield* HostProcess.Platform; + const launcherPid = options?.launcherPid ?? startupParentPid; + const getParentPid = options?.getParentPid ?? (() => process.ppid); + const interval = options?.interval ?? DEFAULT_POLL_INTERVAL; + if (platform === "win32" || launcherPid <= 1) return yield* Effect.never; + const poll: Effect.Effect = Effect.suspend(() => { + const parentPid = getParentPid(); + return parentPid === launcherPid + ? Effect.flatMap(Effect.sleep(interval), () => poll) + : Effect.logWarning("desktop launcher is gone; shutting down the backend", { + launcherPid, + parentPid, + }); }); + return yield* poll; +}); diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 091ff5234d7e..033157554d30 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -101,12 +101,13 @@ export interface WithMetricsOptions { ) => Readonly>; } -const recordMetrics = ( - options: WithMetricsOptions, - startedAt: bigint, - exit: Exit.Exit, -) => - Effect.gen(function* () { +const recordMetrics = + /* Instrumentation must not create its own span for every recorded operation. */ + Effect.fnUntraced(function* ( + options: WithMetricsOptions, + startedAt: bigint, + exit: Exit.Exit, + ) { const duration = Duration.nanos((yield* Clock.monotonicTimeNanos) - startedAt); const baseAttributes = typeof options.attributes === "function" ? options.attributes() : (options.attributes ?? {}); diff --git a/apps/server/src/persistence/DatabaseMaintenance.ts b/apps/server/src/persistence/DatabaseMaintenance.ts index 48d9b390b15c..72af08b04c86 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.ts @@ -126,20 +126,21 @@ const readStorage = Effect.gen(function* () { * refused while any other connection has the file open, even an idle one, * which is how a running server or desktop app is detected. */ -const lockExclusively = (databasePath: string) => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA busy_timeout = 0`; - yield* sql`PRAGMA locking_mode = EXCLUSIVE`; - yield* sql`BEGIN EXCLUSIVE`.pipe( - Effect.mapError((cause) => - isSqlError(cause) && cause.reason._tag === "LockTimeoutError" - ? new DatabaseInUseError({ path: databasePath, cause }) - : cause, - ), - ); - yield* sql`COMMIT`; - }); +const lockExclusively = Effect.fn("DatabaseMaintenance.lockExclusively")(function* ( + databasePath: string, +) { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA busy_timeout = 0`; + yield* sql`PRAGMA locking_mode = EXCLUSIVE`; + yield* sql`BEGIN EXCLUSIVE`.pipe( + Effect.mapError((cause) => + isSqlError(cause) && cause.reason._tag === "LockTimeoutError" + ? new DatabaseInUseError({ path: databasePath, cause }) + : cause, + ), + ); + yield* sql`COMMIT`; +}); const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index be92a8957fac..32461ac69f7f 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -121,31 +121,32 @@ export const make = Effect.gen(function* () { // Old agents keep their scopes when the server is killed, and the restarted // server resumes their sessions. Stop them first, so two copies of one // session never work in the same worktree. - const stopLeftoverAgents = (systemctl: string) => - Effect.gen(function* () { - const shown = yield* run(systemctl, [ - "--user", - "show", - ownedSlice, - "--property=ActiveState,ControlGroup", - ]); - if (shown._tag === "None") return; - const values = parseKeyValues(shown.value.stdout); - if (values.get("ActiveState") !== "active") return; - const controlGroup = values.get("ControlGroup") ?? ""; - const events = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; - if (parseCgroupCounter(events, "populated") === 0) return; - yield* Effect.logWarning("Stopping agents left by a previous server", { slice: ownedSlice }); - const stopped = yield* runner - .run({ command: systemctl, args: ["--user", "stop", ownedSlice], timeout: "30 seconds" }) - .pipe(Effect.option); - if (stopped._tag === "None" || stopped.value.code !== 0) { - yield* Effect.logWarning("Could not stop agents left by a previous server", { - slice: ownedSlice, - stderr: stopped._tag === "Some" ? stopped.value.stderr.trim() : undefined, - }); - } - }); + const stopLeftoverAgents = Effect.fn("agentScope.stopLeftoverAgents")(function* ( + systemctl: string, + ) { + const shown = yield* run(systemctl, [ + "--user", + "show", + ownedSlice, + "--property=ActiveState,ControlGroup", + ]); + if (shown._tag === "None") return; + const values = parseKeyValues(shown.value.stdout); + if (values.get("ActiveState") !== "active") return; + const controlGroup = values.get("ControlGroup") ?? ""; + const events = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; + if (parseCgroupCounter(events, "populated") === 0) return; + yield* Effect.logWarning("Stopping agents left by a previous server", { slice: ownedSlice }); + const stopped = yield* runner + .run({ command: systemctl, args: ["--user", "stop", ownedSlice], timeout: "30 seconds" }) + .pipe(Effect.option); + if (stopped._tag === "None" || stopped.value.code !== 0) { + yield* Effect.logWarning("Could not stop agents left by a previous server", { + slice: ownedSlice, + stderr: stopped._tag === "Some" ? stopped.value.stderr.trim() : undefined, + }); + } + }); const systemd = yield* Effect.cached( Effect.gen(function* () { @@ -202,73 +203,76 @@ export const make = Effect.gen(function* () { const scopeByThread = new Map(); - const readScope = (systemctl: string, unit: string) => - Effect.gen(function* () { - const shown = yield* run(systemctl, [ - "--user", - "show", - unit, - "--property=LoadState,ActiveState,Result,ControlGroup", - ]); - if (shown._tag === "None" || shown.value.code !== 0) return undefined; - const values = parseKeyValues(shown.value.stdout); - const controlGroup = values.get("ControlGroup") ?? ""; - const memoryEvents = controlGroup ? yield* readCgroupFile(controlGroup, "memory.events") : ""; - const cgroupEvents = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; - const populated = parseCgroupCounter(cgroupEvents, "populated"); - return { - loadState: values.get("LoadState") ?? "", - activeState: values.get("ActiveState") ?? "", - result: values.get("Result") ?? "", - oomKills: parseCgroupCounter(memoryEvents, "oom_kill") ?? 0, - populated: populated === undefined ? undefined : populated === 1, - } satisfies ScopeState; - }); + const readScope = Effect.fn("agentScope.readScope")(function* (systemctl: string, unit: string) { + const shown = yield* run(systemctl, [ + "--user", + "show", + unit, + "--property=LoadState,ActiveState,Result,ControlGroup", + ]); + if (shown._tag === "None" || shown.value.code !== 0) return undefined; + const values = parseKeyValues(shown.value.stdout); + const controlGroup = values.get("ControlGroup") ?? ""; + const memoryEvents = controlGroup ? yield* readCgroupFile(controlGroup, "memory.events") : ""; + const cgroupEvents = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; + const populated = parseCgroupCounter(cgroupEvents, "populated"); + return { + loadState: values.get("LoadState") ?? "", + activeState: values.get("ActiveState") ?? "", + result: values.get("Result") ?? "", + oomKills: parseCgroupCounter(memoryEvents, "oom_kill") ?? 0, + populated: populated === undefined ? undefined : populated === 1, + } satisfies ScopeState; + }); - const clearFailed = (systemctl: string, unit: string) => - Effect.gen(function* () { - for (let attempt = 0; attempt < CLEAR_FAILED_ATTEMPTS; attempt++) { - const state = yield* readScope(systemctl, unit); - if (state === undefined) return; - if (state.activeState === "failed") { - yield* run(systemctl, ["--user", "reset-failed", unit]); - return; - } - if (state.activeState !== "deactivating") return; - yield* Effect.sleep(CLEAR_FAILED_INTERVAL); + const clearFailed = Effect.fn("agentScope.clearFailed")(function* ( + systemctl: string, + unit: string, + ) { + for (let attempt = 0; attempt < CLEAR_FAILED_ATTEMPTS; attempt++) { + const state = yield* readScope(systemctl, unit); + if (state === undefined) return; + if (state.activeState === "failed") { + yield* run(systemctl, ["--user", "reset-failed", unit]); + return; } - }); + if (state.activeState !== "deactivating") return; + yield* Effect.sleep(CLEAR_FAILED_INTERVAL); + } + }); const forget = (systemctl: string, unit: string) => clearFailed(systemctl, unit).pipe(Effect.forkIn(layerScope), Effect.asVoid); - const evictOldest = (systemctl: string) => - Effect.gen(function* () { - const oldest = scopeByThread.entries().next(); - if (oldest.done === true) return; - const [threadId, tracked] = oldest.value; - const state = yield* readScope(systemctl, tracked.unit); - if (scopeByThread.get(threadId) !== tracked) return; - scopeByThread.delete(threadId); - const status = state === undefined ? "running" : classifyScope(state); - if (status === "running" || status === "stopping") { - scopeByThread.set(threadId, tracked); - return; - } - yield* forget(systemctl, tracked.unit); - }); + const evictOldest = Effect.fn("agentScope.evictOldest")(function* (systemctl: string) { + const oldest = scopeByThread.entries().next(); + if (oldest.done === true) return; + const [threadId, tracked] = oldest.value; + const state = yield* readScope(systemctl, tracked.unit); + if (scopeByThread.get(threadId) !== tracked) return; + scopeByThread.delete(threadId); + const status = state === undefined ? "running" : classifyScope(state); + if (status === "running" || status === "stopping") { + scopeByThread.set(threadId, tracked); + return; + } + yield* forget(systemctl, tracked.unit); + }); - const track = (systemctl: string, threadId: string, unit: string | undefined) => - Effect.gen(function* () { - const previous = scopeByThread.get(threadId); - scopeByThread.delete(threadId); - if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false }); - if (previous !== undefined) yield* forget(systemctl, previous.unit); - if (scopeByThread.size > MAX_TRACKED_THREADS) yield* evictOldest(systemctl); - }); + const track = Effect.fn("agentScope.track")(function* ( + systemctl: string, + threadId: string, + unit: string | undefined, + ) { + const previous = scopeByThread.get(threadId); + scopeByThread.delete(threadId); + if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false }); + if (previous !== undefined) yield* forget(systemctl, previous.unit); + if (scopeByThread.size > MAX_TRACKED_THREADS) yield* evictOldest(systemctl); + }); - const oomKilled: AgentScopeShape["oomKilled"] = (threadId) => - Effect.gen(function* () { + const oomKilled: AgentScopeShape["oomKilled"] = Effect.fn("agentScope.oomKilled")( + function* (threadId) { const tracked = scopeByThread.get(threadId); if (tracked === undefined) return false; if (tracked.oomKilled) return true; @@ -293,14 +297,14 @@ export const make = Effect.gen(function* () { return true; } return false; - }); + }, + ); - const clear: AgentScopeShape["clear"] = (threadId) => - Effect.gen(function* () { - if (!scopeByThread.has(threadId)) return; - const scope = yield* systemd; - if (scope !== undefined) yield* track(scope.systemctl, threadId, undefined); - }); + const clear: AgentScopeShape["clear"] = Effect.fn("agentScope.clear")(function* (threadId) { + if (!scopeByThread.has(threadId)) return; + const scope = yield* systemd; + if (scope !== undefined) yield* track(scope.systemctl, threadId, undefined); + }); return { wrap: Effect.fn("AgentScope.wrap")(function* ({ diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index e68ddf78b3fc..9d8896cea0fa 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -133,20 +133,21 @@ export const make = Effect.gen(function* () { return next; }).pipe(Effect.andThen(publish)); - const scheduleRemoval = (projectId: ProjectId) => - Effect.gen(function* () { - yield* clearRetention(projectId); - const fiber = yield* remove(projectId).pipe( - Effect.delay(DONE_RETENTION), - Effect.ensuring( - Effect.sync(() => { - if (retentionFibers.get(projectId) === fiber) retentionFibers.delete(projectId); - }), - ), - Effect.forkDetach, - ); - retentionFibers.set(projectId, fiber); - }); + const scheduleRemoval = Effect.fn("ProjectCloneTracker.scheduleRemoval")(function* ( + projectId: ProjectId, + ) { + yield* clearRetention(projectId); + const fiber = yield* remove(projectId).pipe( + Effect.delay(DONE_RETENTION), + Effect.ensuring( + Effect.sync(() => { + if (retentionFibers.get(projectId) === fiber) retentionFibers.delete(projectId); + }), + ), + Effect.forkDetach, + ); + retentionFibers.set(projectId, fiber); + }); const progress = ( projectId: ProjectId, @@ -167,26 +168,25 @@ export const make = Effect.gen(function* () { }, })).pipe(Effect.asVoid); - const finish = ( + const finish = Effect.fn("ProjectCloneTracker.finish")(function* ( projectId: ProjectId, phase: "done" | "failed" | "cancelled", error: string | null, - ) => - Effect.gen(function* () { - const endedAt = yield* nowIso; - yield* modify(projectId, (tracked) => ({ - ...tracked, - fiber: null, - snapshot: { - ...tracked.snapshot, - phase, - endedAt, - percent: phase === "done" ? 100 : tracked.snapshot.percent, - error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), - }, - })); - if (phase === "done") yield* scheduleRemoval(projectId); - }); + ) { + const endedAt = yield* nowIso; + yield* modify(projectId, (tracked) => ({ + ...tracked, + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + percent: phase === "done" ? 100 : tracked.snapshot.percent, + error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), + }, + })); + if (phase === "done") yield* scheduleRemoval(projectId); + }); /** * Runs in its own fiber; the tracker records the outcome through `onExit`, @@ -217,20 +217,19 @@ export const make = Effect.gen(function* () { Effect.ignoreCause(), ); - const launch = (projectId: ProjectId) => - Effect.gen(function* () { - const current = yield* Ref.get(clones); - const tracked = current.get(projectId); - if (!tracked) return; - const fiber = yield* runClone(projectId, tracked).pipe(Effect.forkIn(cloneScope)); - yield* Ref.update(clones, (map) => { - const existing = map.get(projectId); - if (!existing) return map; - const next = new Map(map); - next.set(projectId, { ...existing, fiber }); - return next; - }); + const launch = Effect.fn("ProjectCloneTracker.launch")(function* (projectId: ProjectId) { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if (!tracked) return; + const fiber = yield* runClone(projectId, tracked).pipe(Effect.forkIn(cloneScope)); + yield* Ref.update(clones, (map) => { + const existing = map.get(projectId); + if (!existing) return map; + const next = new Map(map); + next.set(projectId, { ...existing, fiber }); + return next; }); + }); const start: ProjectCloneTracker["Service"]["start"] = Effect.fn("ProjectCloneTracker.start")( function* (input, hooks) { @@ -307,8 +306,8 @@ export const make = Effect.gen(function* () { const get: ProjectCloneTracker["Service"]["get"] = (projectId) => Ref.get(clones).pipe(Effect.map((current) => current.get(projectId)?.snapshot ?? null)); - const cancel: ProjectCloneTracker["Service"]["cancel"] = (projectId) => - Effect.gen(function* () { + const cancel: ProjectCloneTracker["Service"]["cancel"] = Effect.fn("ProjectCloneTracker.cancel")( + function* (projectId) { const current = yield* Ref.get(clones); const tracked = current.get(projectId); if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) return false; @@ -324,10 +323,11 @@ export const make = Effect.gen(function* () { }), ); return true; - }); + }, + ); - const retry: ProjectCloneTracker["Service"]["retry"] = (projectId) => - Effect.gen(function* () { + const retry: ProjectCloneTracker["Service"]["retry"] = Effect.fn("ProjectCloneTracker.retry")( + function* (projectId) { const current = yield* Ref.get(clones); const tracked = current.get(projectId); if ( @@ -353,23 +353,25 @@ export const make = Effect.gen(function* () { })); yield* launch(projectId); return true; - }); + }, + ); - const discard: ProjectCloneTracker["Service"]["discard"] = (projectId) => - Effect.gen(function* () { - const current = yield* Ref.get(clones); - const tracked = current.get(projectId); - if (!tracked) return; - if (tracked.fiber) yield* Fiber.interrupt(tracked.fiber); - // Git may have finished while the interrupt was landing; the project - // is going away either way, but a complete checkout is the user's. - const after = yield* get(projectId); - if (after?.phase !== "done") { - yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); - } - yield* clearRetention(projectId); - yield* remove(projectId); - }); + const discard: ProjectCloneTracker["Service"]["discard"] = Effect.fn( + "ProjectCloneTracker.discard", + )(function* (projectId) { + const current = yield* Ref.get(clones); + const tracked = current.get(projectId); + if (!tracked) return; + if (tracked.fiber) yield* Fiber.interrupt(tracked.fiber); + // Git may have finished while the interrupt was landing; the project + // is going away either way, but a complete checkout is the user's. + const after = yield* get(projectId); + if (after?.phase !== "done") { + yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); + } + yield* clearRetention(projectId); + yield* remove(projectId); + }); const stream: ProjectCloneTracker["Service"]["stream"] = Stream.callback< ReadonlyArray @@ -405,11 +407,15 @@ const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); * client can start a thread on an empty tree, and no attachment copies are made * for a command that is about to be refused. */ -export const rejectCommandsDuringClone = ( - tracker: ProjectCloneTracker["Service"], - command: { readonly type: string; readonly projectId?: ProjectId; readonly bootstrap?: unknown }, -): Effect.Effect => - Effect.gen(function* () { +export const rejectCommandsDuringClone = Effect.fn("ProjectCloneTracker.rejectCommandsDuringClone")( + function* ( + tracker: ProjectCloneTracker["Service"], + command: { + readonly type: string; + readonly projectId?: ProjectId; + readonly bootstrap?: unknown; + }, + ): Effect.fn.Return { const projectId = command.type === "thread.create" ? (command.projectId ?? null) @@ -425,7 +431,8 @@ export const rejectCommandsDuringClone = ( ? "The repository is still being cloned." : "The repository was not cloned. Retry the clone first.", }); - }); + }, +); function bootstrapProjectId(bootstrap: unknown): ProjectId | null { if (typeof bootstrap !== "object" || bootstrap === null) return null; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 75dc4e661ef6..aef40716ae81 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -238,20 +238,19 @@ export const make = Effect.gen(function* () { // 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. - const firstInOrder = ( + const firstInOrder = Effect.fn("ProjectFaviconResolver.firstInOrder")(function* ( items: ReadonlyArray, probe: (item: A) => Effect.Effect, - ) => - Effect.gen(function* () { - const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item))); - for (const fiber of fibers) { - const found = yield* Fiber.join(fiber); - if (found) { - return found; - } + ) { + const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item))); + for (const fiber of fibers) { + const found = yield* Fiber.join(fiber); + if (found) { + return found; } - return null; - }).pipe(Effect.scoped); + } + return null; + }, Effect.scoped); const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( cwd: string, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 67977c5c7f24..6799be186d1a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -191,16 +191,16 @@ export const make = Effect.gen(function* () { yield* HostProcess.Environment, ); - const observeTerminalCompletion = (input: { - readonly threadId: string; - readonly terminalId: string; - readonly sentinel: string; - readonly sentinelPattern: RegExp; - /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ - readonly echoedWrapperLines: ReadonlyArray; - readonly onOutputLine: ((line: string) => Effect.Effect) | undefined; - }) => - Effect.gen(function* () { + const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( + function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly sentinel: string; + readonly sentinelPattern: RegExp; + /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ + readonly echoedWrapperLines: ReadonlyArray; + readonly onOutputLine: ((line: string) => Effect.Effect) | undefined; + }) { const startedAtMs = yield* Clock.currentTimeMillis; const done = yield* Deferred.make(); // The shell redraws its prompt just after the sentinel. Closing before @@ -278,7 +278,8 @@ export const make = Effect.gen(function* () { Effect.ensuring(Effect.sync(() => unsubscribe())), ); return { completion, unsubscribe }; - }); + }, + ); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts index 43639afe4515..0bdf7af4d2b0 100644 --- a/apps/server/src/project/WorktreeSetupTracker.ts +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -147,8 +147,8 @@ export const make = Effect.gen(function* () { Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), ); - const begin: WorktreeSetupTracker["Service"]["begin"] = (input) => - Effect.gen(function* () { + const begin: WorktreeSetupTracker["Service"]["begin"] = Effect.fn("WorktreeSetupTracker.begin")( + function* (input) { yield* clearRetention(input.threadId); const startedAt = yield* nowIso; const ordered = WORKTREE_SETUP_STAGE_ORDER.filter((id) => input.stages.includes(id)); @@ -172,7 +172,8 @@ export const make = Effect.gen(function* () { return next; }); yield* publish(input.threadId, snapshot); - }); + }, + ); const update: WorktreeSetupTracker["Service"]["update"] = (threadId, mutate) => modify(threadId, (tracked) => ({ ...tracked, snapshot: mutate(tracked.snapshot) })).pipe( @@ -235,44 +236,45 @@ export const make = Effect.gen(function* () { ), })); - const finish: WorktreeSetupTracker["Service"]["finish"] = (threadId, phase, error) => - Effect.gen(function* () { - const endedAt = yield* nowIso; - const snapshot = yield* modify(threadId, (tracked) => ({ - fiber: null, - snapshot: { - ...tracked.snapshot, - phase, - endedAt, - error: - error === undefined || error === null - ? null - : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), - stages: tracked.snapshot.stages.map((entry) => - entry.status === "running" - ? { - ...entry, - status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", - endedAt, - } - : entry, - ), - }, - })); - if (!snapshot) return null; - yield* clearRetention(threadId); - const fiber = yield* remove(threadId).pipe( - Effect.delay(FINISHED_RETENTION), - Effect.ensuring( - Effect.sync(() => { - if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); - }), + const finish: WorktreeSetupTracker["Service"]["finish"] = Effect.fn( + "WorktreeSetupTracker.finish", + )(function* (threadId, phase, error) { + const endedAt = yield* nowIso; + const snapshot = yield* modify(threadId, (tracked) => ({ + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + error: + error === undefined || error === null + ? null + : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), + stages: tracked.snapshot.stages.map((entry) => + entry.status === "running" + ? { + ...entry, + status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", + endedAt, + } + : entry, ), - Effect.forkDetach, - ); - retentionFibers.set(threadId, fiber); - return snapshot; - }); + }, + })); + if (!snapshot) return null; + yield* clearRetention(threadId); + const fiber = yield* remove(threadId).pipe( + Effect.delay(FINISHED_RETENTION), + Effect.ensuring( + Effect.sync(() => { + if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); + }), + ), + Effect.forkDetach, + ); + retentionFibers.set(threadId, fiber); + return snapshot; + }); const markUncancellable: WorktreeSetupTracker["Service"]["markUncancellable"] = (threadId) => Ref.update(setups, (current) => { @@ -283,16 +285,17 @@ export const make = Effect.gen(function* () { return next; }); - const cancel: WorktreeSetupTracker["Service"]["cancel"] = (threadId) => - Effect.gen(function* () { - const current = yield* Ref.get(setups); - const tracked = current.get(threadId); - if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { - return false; - } - yield* Fiber.interrupt(tracked.fiber); - return true; - }); + const cancel: WorktreeSetupTracker["Service"]["cancel"] = Effect.fn( + "WorktreeSetupTracker.cancel", + )(function* (threadId) { + const current = yield* Ref.get(setups); + const tracked = current.get(threadId); + if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { + return false; + } + yield* Fiber.interrupt(tracked.fiber); + return true; + }); const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 16860c78ee9a..6f2c107f7f9d 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -153,8 +153,8 @@ export const make = Effect.gen(function* () { lock.withPermits(1), ); }), - invalidate: (scope) => - Effect.gen(function* () { + invalidate: Effect.fn("PullRequestReadCache.invalidate")( + function* (scope) { const now = clock.currentTimeMillisUnsafe(); const current = yield* Cache.get(revisions, undefined); const next = Object.fromEntries( @@ -164,14 +164,14 @@ export const make = Effect.gen(function* () { const encoded = yield* Schema.encodeEffect(revisionCodec)(next); yield* backing.set("revisions", encoded); yield* Cache.set(revisions, undefined, next); - }).pipe( - Effect.catch(() => { - enabled = false; - return Effect.logWarning("PR cache disabled after clearing failed"); - }), - Effect.uninterruptible, - lock.withPermits(CONCURRENT_READS), - ), + }, + Effect.catch(() => { + enabled = false; + return Effect.logWarning("PR cache disabled after clearing failed"); + }), + Effect.uninterruptible, + lock.withPermits(CONCURRENT_READS), + ), }); }); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index ff77f99ebe45..8f82dd8af1e5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1075,294 +1075,291 @@ export const make = Effect.gen(function* () { const searchVisibilityKey = (host: string, repository: string) => `${host}\n${repository.trim().toLowerCase()}`; - const listUncached: PullRequestService["Service"]["list"] = (input) => - Effect.gen(function* () { - const involvement = input.involvement ?? "all"; - const continuation = yield* decodeCursors(input.cursors); - const { - supported: projects, - unimplemented, - viewerRoots, - } = yield* listWorkspaceProjects(input); - const projectCounts = new Map(); - for (const { host } of projects) { - projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); - } + const listUncached: PullRequestService["Service"]["list"] = Effect.fn( + "PullRequestService.listUncached", + )(function* (input) { + const involvement = input.involvement ?? "all"; + const continuation = yield* decodeCursors(input.cursors); + const { supported: projects, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); + const projectCounts = new Map(); + for (const { host } of projects) { + projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); + } - const viewerResults = yield* resolveViewers(projects, viewerRoots); - const viewers: Record = {}; - for (const result of viewerResults) { - if (result.viewer !== null) viewers[result.host] = result.viewer; + const viewerResults = yield* resolveViewers(projects, viewerRoots); + const viewers: Record = {}; + for (const result of viewerResults) { + if (result.viewer !== null) viewers[result.host] = result.viewer; + } + + const providers: ReadonlyArray = [ + ...viewerResults.map((result) => { + const capabilities = projects.find((project) => project.host === result.host)?.api + .capabilities; + return { + host: result.host, + kind: result.kind, + searchesOnHost: capabilities?.search ?? false, + actions: capabilities?.actions ?? [], + projectCount: projectCounts.get(result.host) ?? 1, + configured: result.viewer !== null, + detail: result.error === null ? null : providerDetail(result.error), + }; + }), + ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ + host, + kind, + searchesOnHost: false, + actions: [], + projectCount, + configured: false, + detail: "This host cannot be browsed here yet.", + })), + ]; + + const selected = + continuation === null + ? projects + : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); + const readable = selected.filter(({ host }) => viewers[host] !== undefined); + const unreadable = selected + .filter(({ host }) => viewers[host] === undefined) + .map(({ project, repository }) => ({ + projectId: project.id, + projectTitle: project.title, + message: `${repository} could not be read.`, + })); + if (readable.length === 0) { + const errors = viewerResults.flatMap((result) => + result.error === null || !selected.some(({ host }) => host === result.host) + ? [] + : [result.error], + ); + const blocking = errors.find(isProviderUnusable) ?? errors[0]; + if (blocking) { + return yield* toPullRequestError("list")(blocking); } - const providers: ReadonlyArray = [ - ...viewerResults.map((result) => { - const capabilities = projects.find((project) => project.host === result.host)?.api - .capabilities; - return { - host: result.host, - kind: result.kind, - searchesOnHost: capabilities?.search ?? false, - actions: capabilities?.actions ?? [], - projectCount: projectCounts.get(result.host) ?? 1, - configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), - }; - }), - ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ - host, - kind, - searchesOnHost: false, - actions: [], - projectCount, - configured: false, - detail: "This host cannot be browsed here yet.", - })), - ]; + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: [], + errors: [], + truncated: false, + nextCursors: {}, + }; + } - const selected = - continuation === null - ? projects - : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); - const readable = selected.filter(({ host }) => viewers[host] !== undefined); - const unreadable = selected - .filter(({ host }) => viewers[host] === undefined) - .map(({ project, repository }) => ({ - projectId: project.id, - projectTitle: project.title, - message: `${repository} could not be read.`, - })); - if (readable.length === 0) { - const errors = viewerResults.flatMap((result) => - result.error === null || !selected.some(({ host }) => host === result.host) - ? [] - : [result.error], - ); - const blocking = errors.find(isProviderUnusable) ?? errors[0]; - if (blocking) { - return yield* toPullRequestError("list")(blocking); - } + const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; + const cursorOf = (project: SupportedProject): ListCursor | undefined => + continuation?.get(project.cursorKey); - return { - viewers: viewers as PullRequestListResult["viewers"], - providers, - entries: [], - errors: [], - truncated: false, - nextCursors: {}, - }; + const readRepository = (project: SupportedProject): Effect.Effect => { + { + const viewer = viewers[project.host]!; + const key = project.cursorKey; + const cursor = cursorOf(project); + return project.api + .listChangeRequests({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + state: input.state, + involvement, + viewer, + limit, + query: input.query, + filters: input.filters, + ...(cursor === undefined + ? {} + : { + cursor: { + updatedBefore: cursor.updatedBefore, + delivered: cursor.delivered, + seenAt: cursor.seenAt, + }, + }), + }) + .pipe( + observeRead, + Effect.map(({ value: page, observedAt }): RepositoryBatch => { + // The boundary instant was asked for inclusively, so the rows already sent at it + // come back with the slice. Dropping them here rather than asking for strictly + // older is what keeps their neighbours at the same instant from being skipped. + const items = + cursor === undefined + ? page.items + : page.items.filter( + (item) => + item.updatedAt !== cursor.updatedBefore || + !cursor.seenAt.includes(item.number), + ); + return { + key, + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer, observedAt })), + errors: [], + truncated: page.truncated, + nextCursor: + page.continues && page.truncated + ? nextListCursor(cursor, page.items, items, page.cursorAdvance) + : null, + }; + }), + Effect.orElseSucceed((): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + })), + ); } + }; - const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; - const cursorOf = (project: SupportedProject): ListCursor | undefined => - continuation?.get(project.cursorKey); - - const readRepository = (project: SupportedProject): Effect.Effect => { - { - const viewer = viewers[project.host]!; - const key = project.cursorKey; - const cursor = cursorOf(project); - return project.api - .listChangeRequests({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - state: input.state, - involvement, - viewer, - limit, - query: input.query, - filters: input.filters, - ...(cursor === undefined - ? {} - : { - cursor: { - updatedBefore: cursor.updatedBefore, - delivered: cursor.delivered, - seenAt: cursor.seenAt, - }, - }), - }) - .pipe( - observeRead, - Effect.map(({ value: page, observedAt }): RepositoryBatch => { - // The boundary instant was asked for inclusively, so the rows already sent at it - // come back with the slice. Dropping them here rather than asking for strictly - // older is what keeps their neighbours at the same instant from being skipped. + const readTogether = ( + chunk: ReadonlyArray, + ): Effect.Effect> => { + const first = chunk[0]!; + const readAcross = first.api.listChangeRequestsAcross; + const separately = () => + Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); + if (readAcross === undefined) return separately(); + const viewer = viewers[first.host]!; + const cursor = cursorOf(first); + return readAcross({ + cwd: first.project.workspaceRoot, + host: first.host, + repositories: chunk.map((project) => project.repository), + state: input.state, + involvement, + viewer, + limit, + query: input.query, + filters: input.filters, + ...(cursor === undefined + ? {} + : { 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 = - cursor === undefined - ? page.items - : page.items.filter( + cursorHere === undefined + ? fetched + : fetched.filter( (item) => - item.updatedAt !== cursor.updatedBefore || - !cursor.seenAt.includes(item.number), + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), ); - return { - key, + 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.continues && page.truncated - ? nextListCursor(cursor, page.items, items, page.cursorAdvance) + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) : null, - }; - }), - Effect.orElseSucceed((): RepositoryBatch => ({ - key, - entries: [], - errors: [ - { - projectId: project.project.id, - projectTitle: project.project.title, - message: `${project.repository} could not be read.`, - }, - ], - truncated: false, - nextCursor: null, - })), + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, ); - } - }; - - const readTogether = ( - chunk: ReadonlyArray, - ): Effect.Effect> => { - const first = chunk[0]!; - const readAcross = first.api.listChangeRequestsAcross; - const separately = () => - Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); - if (readAcross === undefined) return separately(); - const viewer = viewers[first.host]!; - const cursor = cursorOf(first); - return readAcross({ - cwd: first.project.workspaceRoot, - host: first.host, - repositories: chunk.map((project) => project.repository), - state: input.state, - involvement, - viewer, - limit, - query: input.query, - filters: input.filters, - ...(cursor === undefined - ? {} - : { 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.catch(separately), - ); - }; + }), + ), + Effect.catch(separately), + ); + }; - const together = new Map>(); - const separate: Array = []; - for (const project of readable) { - if (project.api.listChangeRequestsAcross === undefined) { - separate.push(project); - continue; - } - const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; - const group = together.get(key); - if (group === undefined) together.set(key, [project]); - else group.push(project); + const together = new Map>(); + const separate: Array = []; + for (const project of readable) { + if (project.api.listChangeRequestsAcross === undefined) { + separate.push(project); + continue; } - const reads: Array>> = separate.map((project) => - readRepository(project).pipe(Effect.map((batch) => [batch])), - ); - for (const group of together.values()) { - for (let start = 0; start < group.length; start += REPOSITORY_SEARCH_CHUNK) { - reads.push(readTogether(group.slice(start, start + REPOSITORY_SEARCH_CHUNK))); - } + const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; + const group = together.get(key); + if (group === undefined) together.set(key, [project]); + else group.push(project); + } + const reads: Array>> = separate.map((project) => + readRepository(project).pipe(Effect.map((batch) => [batch])), + ); + for (const group of together.values()) { + for (let start = 0; start < group.length; start += REPOSITORY_SEARCH_CHUNK) { + reads.push(readTogether(group.slice(start, start + REPOSITORY_SEARCH_CHUNK))); } - const batches = (yield* Effect.all(reads, { concurrency: REPOSITORY_CONCURRENCY })).flat(); + } + const batches = (yield* Effect.all(reads, { concurrency: REPOSITORY_CONCURRENCY })).flat(); - const nextCursors: Record = {}; - for (const batch of batches) { - if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; - } + const nextCursors: Record = {}; + for (const batch of batches) { + if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; + } - return { - viewers: viewers as PullRequestListResult["viewers"], - providers, - entries: batches - .flatMap((batch) => batch.entries) - .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), - errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], - truncated: batches.some((batch) => batch.truncated), - nextCursors, - }; - }); + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], + truncated: batches.some((batch) => batch.truncated), + nextCursors, + }; + }); const viewerOf = (project: SupportedProject): Effect.Effect => routingCredential.pipe( @@ -1396,36 +1393,34 @@ export const make = Effect.gen(function* () { return { ...identity, host, provider: "github" as const }; }); - const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = ( - input, - operation, - ) => - Effect.gen(function* () { - if (input.expectedAccountId === undefined) return yield* operation; - const rejected = () => - new PullRequestOperationError({ - operation: "routeIdentity", - detail: "The GitHub 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); - if ( - api?.withVerifiedCredential === undefined || - input.host?.toLowerCase() !== project.host.toLowerCase() - ) { - return yield* rejected(); - } - const result = yield* api - .withVerifiedCredential( - { cwd: project.project.workspaceRoot, host: project.host }, - (identity) => - identity.accountId === input.expectedAccountId - ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) - : Effect.fail(rejected()), - ) - .pipe(Effect.catchTags({ PullRequestProviderError: () => Effect.fail(rejected()) })); - return yield* Effect.fromResult(result); - }); + const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = Effect.fn( + "PullRequestService.withRoutingCredential", + )(function* (input, operation) { + if (input.expectedAccountId === undefined) return yield* operation; + const rejected = () => + new PullRequestOperationError({ + operation: "routeIdentity", + detail: "The GitHub 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); + if ( + api?.withVerifiedCredential === undefined || + input.host?.toLowerCase() !== project.host.toLowerCase() + ) { + return yield* rejected(); + } + const result = yield* api + .withVerifiedCredential( + { cwd: project.project.workspaceRoot, host: project.host }, + (identity) => + identity.accountId === input.expectedAccountId + ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) + : Effect.fail(rejected()), + ) + .pipe(Effect.catchTags({ PullRequestProviderError: () => Effect.fail(rejected()) })); + return yield* Effect.fromResult(result); + }); const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) { const project = yield* requireProject(input); @@ -2351,78 +2346,78 @@ export const make = Effect.gen(function* () { * The line counts for rows already on the page, which the listing left out because on GitHub * they cost more than everything else on the row put together. */ - const listStatsUncached: PullRequestService["Service"]["listStats"] = (input) => - Effect.gen(function* () { - if (input.refs.length === 0) return { stats: [] }; - const { supported } = yield* listWorkspaceProjects({}); - const byProject = new Map(supported.map((project) => [project.project.id, project])); - const wanted = new Map< - string, - { readonly project: SupportedProject; readonly number: number } - >(); - for (const ref of input.refs) { - const project = byProject.get(ref.projectId); - if ( - project === undefined || - project.api.listChangeRequestStats === undefined || - project.repository.toLowerCase() !== ref.repository.trim().toLowerCase() - ) { - continue; - } - wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); - } - const byHost = new Map>(); - for (const entry of wanted.values()) { - const held = byHost.get(entry.project.host); - if (held === undefined) byHost.set(entry.project.host, [entry]); - else held.push(entry); + const listStatsUncached: PullRequestService["Service"]["listStats"] = Effect.fn( + "PullRequestService.listStatsUncached", + )(function* (input) { + if (input.refs.length === 0) return { stats: [] }; + const { supported } = yield* listWorkspaceProjects({}); + const byProject = new Map(supported.map((project) => [project.project.id, project])); + const wanted = new Map< + string, + { readonly project: SupportedProject; readonly number: number } + >(); + for (const ref of input.refs) { + const project = byProject.get(ref.projectId); + if ( + project === undefined || + project.api.listChangeRequestStats === undefined || + project.repository.toLowerCase() !== ref.repository.trim().toLowerCase() + ) { + continue; } - const stats = yield* Effect.forEach( - [...byHost.values()], - (entries) => { - const first = entries[0]!; - const readStats = first.project.api.listChangeRequestStats; - if (readStats === undefined) - return Effect.succeed>([]); - const projectsByRepository = new Map( - entries.map((entry) => [ - `${entry.project.repository.toLowerCase()} ${entry.number}`, - entry.project, - ]), - ); - return readStats({ - cwd: first.project.project.workspaceRoot, - host: first.project.host, - changeRequests: entries.map((entry) => ({ - repository: entry.project.repository, - number: entry.number, - })), - }).pipe( - Effect.map((read) => - read.flatMap((stat): ReadonlyArray => { - const project = projectsByRepository.get( - `${stat.repository.toLowerCase()} ${stat.number}`, - ); - return project === undefined - ? [] - : [ - { - projectId: project.project.id, - repository: project.repository, - number: stat.number, - additions: stat.additions, - deletions: stat.deletions, - }, - ]; - }), - ), - Effect.orElseSucceed((): ReadonlyArray => []), - ); - }, - { concurrency: REPOSITORY_CONCURRENCY }, - ); - return { stats: stats.flat() }; - }); + wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); + } + const byHost = new Map>(); + for (const entry of wanted.values()) { + const held = byHost.get(entry.project.host); + if (held === undefined) byHost.set(entry.project.host, [entry]); + else held.push(entry); + } + const stats = yield* Effect.forEach( + [...byHost.values()], + (entries) => { + const first = entries[0]!; + const readStats = first.project.api.listChangeRequestStats; + if (readStats === undefined) return Effect.succeed>([]); + const projectsByRepository = new Map( + entries.map((entry) => [ + `${entry.project.repository.toLowerCase()} ${entry.number}`, + entry.project, + ]), + ); + return readStats({ + cwd: first.project.project.workspaceRoot, + host: first.project.host, + changeRequests: entries.map((entry) => ({ + repository: entry.project.repository, + number: entry.number, + })), + }).pipe( + Effect.map((read) => + read.flatMap((stat): ReadonlyArray => { + const project = projectsByRepository.get( + `${stat.repository.toLowerCase()} ${stat.number}`, + ); + return project === undefined + ? [] + : [ + { + projectId: project.project.id, + repository: project.repository, + number: stat.number, + additions: stat.additions, + deletions: stat.deletions, + }, + ]; + }), + ), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + return { stats: stats.flat() }; + }); const revalidate = (read: Effect.Effect) => Effect.context().pipe( @@ -3270,30 +3265,29 @@ export const make = Effect.gen(function* () { const fileKey = (oldPath: string, newPath: string) => JSON.stringify([oldPath, newPath]); const sliceKey = (input: PullRequestDiffInput) => JSON.stringify([refCacheKey(input), input.cursor ?? null, input.commit ?? null]); - const read = (input: PullRequestDiffInput) => - Effect.gen(function* () { - const key = sliceKey(input); - const now = yield* Clock.currentTimeMillis; - const cached = held.get(key); - if (cached !== undefined && now - cached.at <= ttlMs) return cached; - const slice = yield* diff(input); - const entry = { - at: now, - files: new Set( - parseDiffFilePaths(slice.patch).map((file) => - fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), - ), + const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { + const key = sliceKey(input); + const now = yield* Clock.currentTimeMillis; + const cached = held.get(key); + if (cached !== undefined && now - cached.at <= ttlMs) return cached; + const slice = yield* diff(input); + const entry = { + at: now, + files: new Set( + parseDiffFilePaths(slice.patch).map((file) => + fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), ), - nextCursor: slice.nextCursor, - }; - held.delete(key); - if (held.size >= DIFF_CACHE_CAPACITY) { - const oldest = held.keys().next().value; - if (oldest !== undefined) held.delete(oldest); - } - held.set(key, entry); - return entry; - }); + ), + nextCursor: slice.nextCursor, + }; + held.delete(key); + if (held.size >= DIFF_CACHE_CAPACITY) { + const oldest = held.keys().next().value; + if (oldest !== undefined) held.delete(oldest); + } + held.set(key, entry); + return entry; + }); return { read: credentialCached(read), fileKey }; })(); /** diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 59402ee1d9c7..f172a1fe567e 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -196,11 +196,11 @@ export const make = (dependencies: Dependencies) => { cause, }); - const environmentFilesViewed = ( - project: SupportedProject, - ref: PullRequestRef, - ): Effect.Effect => - Effect.gen(function* () { + const environmentFilesViewed = Effect.fn("pullRequestViewedFiles.environmentFilesViewed")( + function* ( + project: SupportedProject, + ref: PullRequestRef, + ): Effect.fn.Return { const viewer = yield* requiredViewerOf(project, "filesViewed"); const held = yield* filesViewedStore .list(filesViewedScope(project, ref.number, viewer)) @@ -234,7 +234,8 @@ export const make = (dependencies: Dependencies) => { }), truncated: held.truncated, }; - }); + }, + ); /** * A tick's host round trip is slower than an untick's, so unordered presses could finish @@ -271,11 +272,11 @@ export const make = (dependencies: Dependencies) => { ); }); - const environmentSetFilesViewed = ( - project: SupportedProject, - input: PullRequestSetFilesViewedInput, - ): Effect.Effect => - Effect.gen(function* () { + const environmentSetFilesViewed = Effect.fn("pullRequestViewedFiles.environmentSetFilesViewed")( + function* ( + project: SupportedProject, + input: PullRequestSetFilesViewedInput, + ): Effect.fn.Return { const viewer = yield* requiredViewerOf(project, "setFilesViewed"); const cleared = input.files.filter((file) => file.viewed).map((file) => file.path); const revisions = @@ -304,7 +305,8 @@ export const make = (dependencies: Dependencies) => { viewedAt, }) .pipe(Effect.mapError(toFilesViewedStoreError("setFilesViewed"))); - }); + }, + ); const filesViewed = (input: PullRequestRef) => requireProject(input).pipe( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 285f2cfe1f9b..8811bd3d6d8a 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -15,6 +15,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -24,6 +25,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import * as TestClock from "effect/testing/TestClock"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; @@ -373,6 +375,29 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("exports the publication span through the relay tracer", () => { + const relaySpans: string[] = []; + const localSpans: string[] = []; + const collectingTracer = (spans: string[]) => + Tracer.make({ + span: (options) => { + spans.push(options.name); + return new Tracer.NativeSpan(options); + }, + }); + + return Effect.gen(function* () { + const { relay, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + assert.include(relaySpans, "relay.agent_activity.publish"); + assert.notInclude(localSpans, "relay.agent_activity.publish"); + }).pipe( + Effect.provideService(RelayClientTracer, Option.some(collectingTracer(relaySpans))), + Effect.withTracer(collectingTracer(localSpans)), + ); + }); + it.effect("deduplicates state and republishes title changes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index fb21aa6e70dd..87ce4afb08c8 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -462,54 +462,53 @@ export const make = Effect.gen(function* () { } const environmentId = yield* serverEnvironment.getEnvironmentId; - const publishState = (input: { + const publishState = Effect.fn("relay.agent_activity.publish")(function* (input: { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; readonly reason: string; - }) => - Effect.gen(function* () { - if (cachedRelayClient === undefined) { - cachedRelayClient = yield* makeRelayClient(relayConfig); - } - const relayClient = cachedRelayClient; - const proof = yield* makePublishProof({ - privateKey: cloudLinkKeyPair.privateKey, - relayIssuer: relayConfig.issuer, - environmentId, - threadId, - state: input.state, - jti: yield* crypto.randomUUIDv4(), - }); + }) { + if (cachedRelayClient === undefined) { + cachedRelayClient = yield* makeRelayClient(relayConfig); + } + const relayClient = cachedRelayClient; + const proof = yield* makePublishProof({ + privateKey: cloudLinkKeyPair.privateKey, + relayIssuer: relayConfig.issuer, + environmentId, + threadId, + state: input.state, + jti: yield* crypto.randomUUIDv4(), + }); - yield* Effect.logInfo("publishing agent activity for thread", { - environmentId, - threadId, - projectId: input.projectId, - statePhase: input.state?.phase ?? null, - hasState: input.state !== null, - reason: input.reason, - }); + yield* Effect.logInfo("publishing agent activity for thread", { + environmentId, + threadId, + projectId: input.projectId, + statePhase: input.state?.phase ?? null, + hasState: input.state !== null, + reason: input.reason, + }); - const response = yield* relayClient.server - .publishAgentActivity({ - params: { - environmentId, - threadId, - }, - payload: { - state: input.state, - proof, - }, - }) - .pipe(Effect.timeout("10 seconds")); - - yield* Effect.logInfo("agent activity publish completed", { - environmentId, - threadId, - ok: response.ok, - deliveries: deliveryStats(response.deliveries), - }); - }).pipe(Effect.withSpan("relay.agent_activity.publish"), withRelayClientTracing); + const response = yield* relayClient.server + .publishAgentActivity({ + params: { + environmentId, + threadId, + }, + payload: { + state: input.state, + proof, + }, + }) + .pipe(Effect.timeout("10 seconds")); + + yield* Effect.logInfo("agent activity publish completed", { + environmentId, + threadId, + ok: response.ok, + deliveries: deliveryStats(response.deliveries), + }); + }); // Per-thread shell read: this publish runs for every activity-relevant // domain event, so materializing the full shell here would make the cost @@ -614,7 +613,7 @@ export const make = Effect.gen(function* () { projectId: snapshot.projectId, state: snapshot.state, reason: snapshot.reason, - }); + }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); @@ -729,31 +728,33 @@ export const make = Effect.gen(function* () { // Many environments never link, so while unlinked the retry backs off from 5 // s to 60 s. A linked environment keeps the 5 s retry, because `t3 connect - // publish` can turn publishing on from another process. - const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => - Effect.gen(function* () { - let unlinkedRetryDelayMs = 5_000; - while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const result = yield* publishActiveThreadsUnsafe.pipe( - Effect.orElseSucceed(() => "failed" as const), - ); - if (result === "published") { - yield* Ref.set(activeSnapshotPublishedRef, true); - if (logEnabledWhenReady) { - const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); - yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { - relayUrl: relayConfig?.url, - }); - } - return; - } - const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; - yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); - if (result === "unlinked") { - unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + // publish` can turn publishing on from another process. Keep this untraced: + // an unlinked environment can retry for the lifetime of the process. + const publishActiveThreadsOnceWhenConfigured = Effect.fnUntraced(function* ( + logEnabledWhenReady: boolean, + ) { + let unlinkedRetryDelayMs = 5_000; + while (!(yield* Ref.get(activeSnapshotPublishedRef))) { + const result = yield* publishActiveThreadsUnsafe.pipe( + Effect.orElseSucceed(() => "failed" as const), + ); + if (result === "published") { + yield* Ref.set(activeSnapshotPublishedRef, true); + if (logEnabledWhenReady) { + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { + relayUrl: relayConfig?.url, + }); } + return; } - }); + const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; + yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); + if (result === "unlinked") { + unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + } + } + }); schedulePublishConfirm = (threadId) => Effect.forkIn( diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 9d252fadb4f7..5ec3664bf214 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -279,23 +279,22 @@ export function resolveNativeSampleIntervalMs( return SAMPLE_INTERVAL_MS; } -export function commitCollectionControlUpdate( +// Desktop power samples also commit control state; avoid a span for each sample. +export const commitCollectionControlUpdate = Effect.fnUntraced(function* ( desiredState: Ref.Ref, appliedState: Ref.Ref, update: (current: CollectionControl) => CollectionControl, apply: (previous: CollectionControl, next: CollectionControl) => Effect.Effect, -): Effect.Effect { - return Effect.gen(function* () { - const [previousDesired, next] = yield* Ref.modify(desiredState, (previous) => { - const next = update(previous); - return [[previous, next] as const, next]; - }); - const previousApplied = yield* Ref.get(appliedState); - yield* apply(previousApplied, next); - yield* Ref.set(appliedState, next); - return [previousDesired, next] as const; +): Effect.fn.Return { + const [previousDesired, next] = yield* Ref.modify(desiredState, (previous) => { + const next = update(previous); + return [[previous, next] as const, next]; }); -} + const previousApplied = yield* Ref.get(appliedState); + yield* apply(previousApplied, next); + yield* Ref.set(appliedState, next); + return [previousDesired, next] as const; +}); export function synchronizeCollectionControlOnStart( mutex: Semaphore.Semaphore, @@ -411,23 +410,22 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu Effect.asVoid, ); - const failPending = (error: NativeTelemetryClientError) => - Effect.gen(function* () { - const samples = yield* Ref.getAndSet(pendingSamples, new Map()); - const processTables = yield* Ref.getAndSet(pendingProcessTables, new Map()); - const histories = yield* Ref.getAndSet(pendingHistories, new Map()); - yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), { - discard: true, - }); - yield* Effect.forEach(processTables.values(), (deferred) => Deferred.fail(deferred, error), { - discard: true, - }); - yield* Effect.forEach( - histories.values(), - (request) => Deferred.fail(request.deferred, error), - { discard: true }, - ); + const failPending = Effect.fn("NativeTelemetryClient.failPending")(function* ( + error: NativeTelemetryClientError, + ) { + const samples = yield* Ref.getAndSet(pendingSamples, new Map()); + const processTables = yield* Ref.getAndSet(pendingProcessTables, new Map()); + const histories = yield* Ref.getAndSet(pendingHistories, new Map()); + yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), { + discard: true, + }); + yield* Effect.forEach(processTables.values(), (deferred) => Deferred.fail(deferred, error), { + discard: true, + }); + yield* Effect.forEach(histories.values(), (request) => Deferred.fail(request.deferred, error), { + discard: true, }); + }); const writeCommand = ( handle: ChildProcessSpawner.ChildProcessHandle, @@ -777,9 +775,11 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu Effect.forkScoped, ); - const applyCollectionControl = Effect.fn( - "resourceTelemetry.nativeTelemetryClient.applyCollectionControl", - )(function* (previous: CollectionControl, next: CollectionControl) { + // Desktop power samples reach this even when the control state has not changed. + const applyCollectionControl = Effect.fnUntraced(function* ( + previous: CollectionControl, + next: CollectionControl, + ) { const current = yield* Ref.get(state); if (canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { const handle = Option.getOrThrow(current.handle); @@ -842,10 +842,9 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }), ); - const setExternalProcesses: NativeTelemetryClient["Service"]["setExternalProcesses"] = ( - processes, - ) => - Effect.gen(function* () { + // Desktop telemetry refreshes this process set on every sample. + const setExternalProcesses: NativeTelemetryClient["Service"]["setExternalProcesses"] = + Effect.fnUntraced(function* (processes) { yield* Ref.set(externalProcesses, [...processes]); const current = yield* Ref.get(state); if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) return; @@ -856,64 +855,65 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu }); }); - const readHistory: NativeTelemetryClient["Service"]["readHistory"] = (windowMs) => - Effect.gen(function* () { - const current = yield* Ref.get(state); - if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { - return yield* new NativeTelemetryUnavailable({ - reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), - }); - } - const requestId = yield* crypto.randomUUIDv4().pipe( - Effect.mapError( - (cause) => - new NativeTelemetryCommandFailed({ - operation: "createHistoryRequestId", - cause, - }), - ), - ); - const deferred = yield* Deferred.make< - ReadonlyArray, - NativeTelemetryClientError - >(); - yield* Ref.update(pendingHistories, (pending) => { - const next = new Map(pending); - next.set(requestId, { deferred, snapshots: [] }); - return next; + const readHistory: NativeTelemetryClient["Service"]["readHistory"] = Effect.fn( + "NativeTelemetryClient.readHistory", + )(function* (windowMs) { + const current = yield* Ref.get(state); + if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { + return yield* new NativeTelemetryUnavailable({ + reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), }); - return yield* writeCommand(Option.getOrThrow(current.handle), { - version: RESOURCE_MONITOR_PROTOCOL_VERSION, - type: "readHistory", - requestId, - windowMs: Math.max(0, Math.round(windowMs)), - }).pipe( - Effect.andThen( - Deferred.await(deferred).pipe( - Effect.timeoutOption(HISTORY_REQUEST_TIMEOUT), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new NativeTelemetryRequestTimedOut({ - operation: "readHistory", - timeoutMs: Duration.toMillis(HISTORY_REQUEST_TIMEOUT), - }), - ), - onSome: Effect.succeed, - }), - ), - ), - ), - Effect.ensuring( - Ref.update(pendingHistories, (pending) => { - const next = new Map(pending); - next.delete(requestId); - return next; + } + const requestId = yield* crypto.randomUUIDv4().pipe( + Effect.mapError( + (cause) => + new NativeTelemetryCommandFailed({ + operation: "createHistoryRequestId", + cause, }), - ), - ); + ), + ); + const deferred = yield* Deferred.make< + ReadonlyArray, + NativeTelemetryClientError + >(); + yield* Ref.update(pendingHistories, (pending) => { + const next = new Map(pending); + next.set(requestId, { deferred, snapshots: [] }); + return next; }); + return yield* writeCommand(Option.getOrThrow(current.handle), { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "readHistory", + requestId, + windowMs: Math.max(0, Math.round(windowMs)), + }).pipe( + Effect.andThen( + Deferred.await(deferred).pipe( + Effect.timeoutOption(HISTORY_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new NativeTelemetryRequestTimedOut({ + operation: "readHistory", + timeoutMs: Duration.toMillis(HISTORY_REQUEST_TIMEOUT), + }), + ), + onSome: Effect.succeed, + }), + ), + ), + ), + Effect.ensuring( + Ref.update(pendingHistories, (pending) => { + const next = new Map(pending); + next.delete(requestId); + return next; + }), + ), + ); + }); const sampleNow: NativeTelemetryClient["Service"]["sampleNow"] = Effect.gen(function* () { const current = yield* Ref.get(state); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts index 0563ab7b107a..2af6d98da0d1 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -333,8 +333,9 @@ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(functi const ingestNative = (snapshot: NativeTelemetryClient.NativeTelemetrySnapshot) => rebuild({ nativeSnapshot: snapshot, updatePrevious: true }); - const ingestDesktop = (snapshot: DesktopHostTelemetrySnapshot) => - Effect.gen(function* () { + const ingestDesktop = + /* Desktop samples arrive continuously; avoid a span per telemetry sample. */ + Effect.fnUntraced(function* (snapshot: DesktopHostTelemetrySnapshot) { const electronRoot = snapshot.electronProcesses.find( (process) => process.pid === snapshot.electronPid, ); @@ -412,39 +413,38 @@ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(functi ); const liveChanges = Stream.unwrap(Effect.map(subscribe, ({ changes }) => changes)); - const readHistory: ResourceTelemetry["Service"]["readHistory"] = (input) => - Effect.gen(function* () { - const readAt = yield* DateTime.now; - const normalizedInput = normalizeResourceTelemetryHistoryInput(input); - const historyResult = yield* Effect.result( - nativeClient.readHistory(normalizedInput.windowMs), - ); - if (Result.isFailure(historyResult)) { - yield* Effect.logWarning("Failed to read native resource telemetry history", { - cause: historyResult.failure.message, - }); - } - const [nativeHealth, desktopHealth] = yield* Effect.all([ - nativeClient.health, - desktopReceiver.health, - ]); - const current = yield* Ref.get(state); - return buildResourceTelemetryHistory({ - readAt, - windowMs: normalizedInput.windowMs, - bucketMs: normalizedInput.bucketMs, - sampleIntervalMs: nativeHealth.sampleIntervalMs, - serverPid: process.pid, - sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), - desktopSnapshot: current.desktopSnapshot, - snapshots: Result.isSuccess(historyResult) ? historyResult.success : [], - health: buildHealth({ - native: nativeHealth, - desktop: desktopHealth, - nativeSnapshot: current.nativeSnapshot, - }), + const readHistory: ResourceTelemetry["Service"]["readHistory"] = Effect.fn( + "ResourceTelemetry.readHistory", + )(function* (input) { + const readAt = yield* DateTime.now; + const normalizedInput = normalizeResourceTelemetryHistoryInput(input); + const historyResult = yield* Effect.result(nativeClient.readHistory(normalizedInput.windowMs)); + if (Result.isFailure(historyResult)) { + yield* Effect.logWarning("Failed to read native resource telemetry history", { + cause: historyResult.failure.message, }); + } + const [nativeHealth, desktopHealth] = yield* Effect.all([ + nativeClient.health, + desktopReceiver.health, + ]); + const current = yield* Ref.get(state); + return buildResourceTelemetryHistory({ + readAt, + windowMs: normalizedInput.windowMs, + bucketMs: normalizedInput.bucketMs, + sampleIntervalMs: nativeHealth.sampleIntervalMs, + serverPid: process.pid, + sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid), + desktopSnapshot: current.desktopSnapshot, + snapshots: Result.isSuccess(historyResult) ? historyResult.success : [], + health: buildHealth({ + native: nativeHealth, + desktop: desktopHealth, + nativeSnapshot: current.nativeSnapshot, + }), }); + }); yield* nativeHealthSubscription.changes.pipe( Stream.runForEach(() => refreshHealth), Effect.forkScoped, diff --git a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts index 767d2fa7bb96..abf7c148acc7 100644 --- a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts +++ b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts @@ -44,8 +44,8 @@ export class RelayDeliveryProof extends Context.Service< const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const environment = yield* ServerEnvironment.ServerEnvironment; - const verify: RelayDeliveryProof["Service"]["verify"] = (input) => - Effect.gen(function* () { + const verify: RelayDeliveryProof["Service"]["verify"] = Effect.fn("webhook.verifyRelayDelivery")( + function* (input) { const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER]; const deliveryId = input.headers["x-t3-relay-delivery-id"]; const receivedAt = input.headers["x-t3-relay-received-at"]; @@ -80,7 +80,9 @@ const make = Effect.gen(function* () { return Option.none(); } return Option.some({ deliveryId, receivedAt }); - }).pipe(Effect.orElseSucceed(Option.none), Effect.withSpan("webhook.verifyRelayDelivery")); + }, + Effect.orElseSucceed(Option.none), + ); return RelayDeliveryProof.of({ verify }); }); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index d259d0b657a0..99e215be7dcb 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -281,8 +281,9 @@ function webhookEndpoint( }; } -const decodeRow = (row: ScheduledTaskRow, origin: WebhookOrigin | null = null) => - Effect.gen(function* () { +// Runs per task row; list and lookup operations already own the span. +const decodeRow = Effect.fnUntraced( + function* (row: ScheduledTaskRow, origin: WebhookOrigin | null = null) { const schedule = yield* decodeScheduleJson(row.schedule_json); const webhook = schedule.type === "webhook" ? webhookEndpoint(row, origin) : undefined; const workspaceStrategy = yield* decodeWorkspaceStrategyJson(row.workspace_strategy_json); @@ -310,15 +311,18 @@ const decodeRow = (row: ScheduledTaskRow, origin: WebhookOrigin | null = null) = runCount: row.run_count, ...(webhook === undefined ? {} : { webhook }), }); - }).pipe( - Effect.mapError((cause) => { - const taskId = decodeTaskId(row.task_id); - return taskError("Could not decode schedule task row.", { - ...(Option.isSome(taskId) ? { taskId: taskId.value } : {}), - cause, - }); - }), - ); + }, + (effect, row, _origin?) => + effect.pipe( + Effect.mapError((cause) => { + const taskId = decodeTaskId(row.task_id); + return taskError("Could not decode schedule task row.", { + ...(Option.isSome(taskId) ? { taskId: taskId.value } : {}), + cause, + }); + }), + ), +); export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(function* ( now: DateTime.DateTime, @@ -639,8 +643,8 @@ export const layer = Layer.effect( // 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 and run_count must count the attempt. - const releaseStuckRun = (task: ScheduledTask, message: string) => - Effect.gen(function* () { + const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( + function* (task: ScheduledTask, message: string) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -655,14 +659,17 @@ export const layer = Layer.effect( WHERE task_id = ${task.id} AND last_run_status = 'running' `; yield* notifyChanged; - }).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not release stuck schedule task run", { - taskId: task.id, - cause, - }), + }, + (effect, task, _message) => + effect.pipe( + Effect.catch((cause) => + Effect.logWarning("Could not release stuck schedule task run", { + taskId: task.id, + cause, + }), + ), ), - ); + ); const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( task: ScheduledTask, @@ -935,130 +942,131 @@ export const layer = Layer.effect( }), ); - const upsert: ScheduledTaskService["Service"]["upsert"] = (input) => - Effect.gen(function* () { - const now = yield* localNow; - const uuid = - input.commandId === undefined - ? yield* crypto - .randomUUIDv4() - .pipe( - Effect.mapError((cause) => - taskError("Could not generate schedule task id.", { cause }), - ), - ) - : null; - const id = - input.id ?? - ScheduledTaskId.make( - input.commandId ? `scheduled-task:${input.commandId}` : `scheduled-task:${uuid}`, - ); - // Look up by the *resolved* id so idempotent creates (commandId replays) - // keep their run history, and so real load failures propagate instead - // of silently resetting an existing row. - const existingTask = yield* findTask(id); - // Keep the existing next_run_at when the schedule itself is untouched: - // editing a title or prompt must not postpone (or resurrect) a due - // run — only schedule/enabled changes restart the clock. - const schedule: ScheduledTask["schedule"] = - input.schedule.type === "webhook" - ? { - type: "webhook", - signature: - input.schedule.signature == null - ? null - : { - header: input.schedule.signature.header.toLowerCase(), - encoding: input.schedule.signature.encoding, - prefix: input.schedule.signature.prefix, - }, - maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null, + const upsert: ScheduledTaskService["Service"]["upsert"] = Effect.fn( + "ScheduledTaskService.upsert", + )(function* (input) { + const now = yield* localNow; + const uuid = + input.commandId === undefined + ? yield* crypto + .randomUUIDv4() + .pipe( + Effect.mapError((cause) => + taskError("Could not generate schedule task id.", { cause }), + ), + ) + : null; + const id = + input.id ?? + ScheduledTaskId.make( + input.commandId ? `scheduled-task:${input.commandId}` : `scheduled-task:${uuid}`, + ); + // Look up by the *resolved* id so idempotent creates (commandId replays) + // keep their run history, and so real load failures propagate instead + // of silently resetting an existing row. + const existingTask = yield* findTask(id); + // Keep the existing next_run_at when the schedule itself is untouched: + // editing a title or prompt must not postpone (or resurrect) a due + // run — only schedule/enabled changes restart the clock. + const schedule: ScheduledTask["schedule"] = + input.schedule.type === "webhook" + ? { + type: "webhook", + signature: + input.schedule.signature == null + ? null + : { + header: input.schedule.signature.header.toLowerCase(), + encoding: input.schedule.signature.encoding, + prefix: input.schedule.signature.prefix, + }, + maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null, + } + : input.schedule; + const webhook = + input.schedule.type === "webhook" + ? yield* Effect.gen(function* () { + const existing = existingTask === null ? null : yield* findWebhookCredentials(id); + const token = existing?.token ?? (yield* newWebhookToken); + const signature = input.schedule.type === "webhook" ? input.schedule.signature : null; + // A secretRef is a value the user entered for an agent; this + // save consumes it, so it cannot be used again. A replay of a + // save that already used it keeps the secret that save stored. + const replay = input.commandId !== undefined && existing?.secret != null; + const fromRef = + signature?.secretRef === undefined + ? undefined + : yield* secretRequests + .consume({ ref: signature.secretRef, projectId: input.projectId }) + .pipe( + Effect.catch((error) => + replay + ? Effect.succeed(undefined) + : Effect.fail(taskError(error.message, { taskId: id })), + ), + ); + // A ref, when given, is the only source: a plain secret sent + // alongside it must not replace what a replayed save stored. + const provided = signature?.secretRef === undefined ? signature?.secret : fromRef; + const secret = signature == null ? null : (provided ?? existing?.secret ?? null); + const secretChanged = + signature == null || provided !== undefined || existing === null; + if (signature != null && secret === null) { + return yield* taskError("A webhook signature check needs a signing secret.", { + taskId: id, + }); } - : input.schedule; - const webhook = - input.schedule.type === "webhook" - ? yield* Effect.gen(function* () { - const existing = existingTask === null ? null : yield* findWebhookCredentials(id); - const token = existing?.token ?? (yield* newWebhookToken); - const signature = - input.schedule.type === "webhook" ? input.schedule.signature : null; - // A secretRef is a value the user entered for an agent; this - // save consumes it, so it cannot be used again. A replay of a - // save that already used it keeps the secret that save stored. - const replay = input.commandId !== undefined && existing?.secret != null; - const fromRef = - signature?.secretRef === undefined - ? undefined - : yield* secretRequests - .consume({ ref: signature.secretRef, projectId: input.projectId }) - .pipe( - Effect.catch((error) => - replay - ? Effect.succeed(undefined) - : Effect.fail(taskError(error.message, { taskId: id })), - ), - ); - // A ref, when given, is the only source: a plain secret sent - // alongside it must not replace what a replayed save stored. - const provided = signature?.secretRef === undefined ? signature?.secret : fromRef; - const secret = signature == null ? null : (provided ?? existing?.secret ?? null); - const secretChanged = - signature == null || provided !== undefined || existing === null; - if (signature != null && secret === null) { - return yield* taskError("A webhook signature check needs a signing secret.", { - taskId: id, - }); - } - return { token, secret, secretChanged }; - }) - : { token: null, secret: null, secretChanged: true }; - const workspaceStrategy: ScheduledTask["workspaceStrategy"] = - input.workspaceStrategy.type === "worktree" && - (yield* managedFolders.isScratchProject(input.projectId)) - ? { type: "root" } - : input.workspaceStrategy; - const scheduleUnchanged = - existingTask !== null && - existingTask.enabled === input.enabled && - isSameSchedule(existingTask.schedule, schedule); - const task: ScheduledTask = { - id, - title: input.title, - prompt: input.prompt, - enabled: input.enabled, - schedule, - projectId: input.projectId, - threadId: input.threadId ?? null, - workspaceStrategy, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - createdBy: existingTask?.createdBy ?? input.createdBy ?? "user", - creationSource: input.creationSource ?? "web", - createdAt: existingTask?.createdAt ?? iso(now), - updatedAt: iso(now), - nextRunAt: scheduleUnchanged - ? existingTask.nextRunAt - : nextRunAt({ enabled: input.enabled, schedule }, now), - lastRunAt: existingTask?.lastRunAt ?? null, - lastRunStatus: existingTask?.lastRunStatus ?? "never", - lastRunError: existingTask?.lastRunError ?? null, - runCount: existingTask?.runCount ?? 0, - }; - yield* saveTask(task, input.requireExisting === true, webhook); - yield* notifyChanged; - return { task: (yield* findTask(id)) ?? task }; - }); + return { token, secret, secretChanged }; + }) + : { token: null, secret: null, secretChanged: true }; + const workspaceStrategy: ScheduledTask["workspaceStrategy"] = + input.workspaceStrategy.type === "worktree" && + (yield* managedFolders.isScratchProject(input.projectId)) + ? { type: "root" } + : input.workspaceStrategy; + const scheduleUnchanged = + existingTask !== null && + existingTask.enabled === input.enabled && + isSameSchedule(existingTask.schedule, schedule); + const task: ScheduledTask = { + id, + title: input.title, + prompt: input.prompt, + enabled: input.enabled, + schedule, + projectId: input.projectId, + threadId: input.threadId ?? null, + workspaceStrategy, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + createdBy: existingTask?.createdBy ?? input.createdBy ?? "user", + creationSource: input.creationSource ?? "web", + createdAt: existingTask?.createdAt ?? iso(now), + updatedAt: iso(now), + nextRunAt: scheduleUnchanged + ? existingTask.nextRunAt + : nextRunAt({ enabled: input.enabled, schedule }, now), + lastRunAt: existingTask?.lastRunAt ?? null, + lastRunStatus: existingTask?.lastRunStatus ?? "never", + lastRunError: existingTask?.lastRunError ?? null, + runCount: existingTask?.runCount ?? 0, + }; + yield* saveTask(task, input.requireExisting === true, webhook); + yield* notifyChanged; + return { task: (yield* findTask(id)) ?? task }; + }); - const setEnabled: ScheduledTaskService["Service"]["setEnabled"] = (input) => - Effect.gen(function* () { - const existing = yield* loadTask(input.id); - if (existing.enabled === input.enabled) return { task: existing }; - const now = yield* localNow; - const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); - // RETURNING so a task deleted between the load and this UPDATE is a - // visible not-found error, not a false success. - const updated = yield* sql<{ task_id: string }>` + const setEnabled: ScheduledTaskService["Service"]["setEnabled"] = Effect.fn( + "ScheduledTaskService.setEnabled", + )(function* (input) { + const existing = yield* loadTask(input.id); + if (existing.enabled === input.enabled) return { task: existing }; + const now = yield* localNow; + const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); + // RETURNING so a task deleted between the load and this UPDATE is a + // visible not-found error, not a false success. + const updated = yield* sql<{ task_id: string }>` UPDATE scheduled_tasks SET enabled = ${input.enabled ? 1 : 0}, next_run_at = ${next}, @@ -1066,18 +1074,18 @@ export const layer = Layer.effect( WHERE task_id = ${input.id} RETURNING task_id `.pipe( - Effect.mapError((cause) => - taskError("Could not update schedule task.", { taskId: input.id, cause }), - ), - ); - if (updated.length === 0) { - return yield* taskError("Schedule task not found.", { taskId: input.id }); - } - yield* notifyChanged; - return { - task: { ...existing, enabled: input.enabled, nextRunAt: next, updatedAt: iso(now) }, - }; - }); + Effect.mapError((cause) => + taskError("Could not update schedule task.", { taskId: input.id, cause }), + ), + ); + if (updated.length === 0) { + return yield* taskError("Schedule task not found.", { taskId: input.id }); + } + yield* notifyChanged; + return { + task: { ...existing, enabled: input.enabled, nextRunAt: next, updatedAt: iso(now) }, + }; + }); const deleteTask: ScheduledTaskService["Service"]["delete"] = (input) => deleteRow(input.id).pipe( @@ -1099,48 +1107,50 @@ export const layer = Layer.effect( Effect.as({ id: input.id }), ); - const runNow: ScheduledTaskService["Service"]["runNow"] = (input: ScheduledTaskRunNowInput) => - Effect.gen(function* () { - const task = yield* loadTask(input.id); - if (task.schedule.type === "webhook") { - return yield* taskError("Webhook tasks run when their URL receives a request.", { - taskId: input.id, - }); - } - const next = yield* runTask(task, "manual").pipe( - Effect.mapError((cause) => - taskError("Could not run schedule task.", { taskId: input.id, cause }), - ), - ); - return { task: next }; - }); + const runNow: ScheduledTaskService["Service"]["runNow"] = Effect.fn( + "ScheduledTaskService.runNow", + )(function* (input: ScheduledTaskRunNowInput) { + const task = yield* loadTask(input.id); + if (task.schedule.type === "webhook") { + return yield* taskError("Webhook tasks run when their URL receives a request.", { + taskId: input.id, + }); + } + const next = yield* runTask(task, "manual").pipe( + Effect.mapError((cause) => + taskError("Could not run schedule task.", { taskId: input.id, cause }), + ), + ); + return { task: next }; + }); - const rotateWebhookToken: ScheduledTaskService["Service"]["rotateWebhookToken"] = (input) => - Effect.gen(function* () { - const task = yield* loadTask(input.id); - if (task.schedule.type !== "webhook") { - return yield* taskError("Only webhook tasks have a URL token.", { taskId: input.id }); - } - const token = yield* newWebhookToken; - const now = yield* localNow; - // Matching created_at keeps a rotation from landing on a task deleted - // and recreated under the same id since it was loaded. - const updated = yield* sql<{ task_id: string }>` + const rotateWebhookToken: ScheduledTaskService["Service"]["rotateWebhookToken"] = Effect.fn( + "ScheduledTaskService.rotateWebhookToken", + )(function* (input) { + const task = yield* loadTask(input.id); + if (task.schedule.type !== "webhook") { + return yield* taskError("Only webhook tasks have a URL token.", { taskId: input.id }); + } + const token = yield* newWebhookToken; + const now = yield* localNow; + // Matching created_at keeps a rotation from landing on a task deleted + // and recreated under the same id since it was loaded. + const updated = yield* sql<{ task_id: string }>` UPDATE scheduled_tasks SET webhook_token = ${token}, updated_at = ${iso(now)} WHERE task_id = ${input.id} AND created_at = ${task.createdAt} RETURNING task_id `.pipe( - Effect.mapError((cause) => - taskError("Could not rotate webhook token.", { taskId: input.id, cause }), - ), - ); - if (updated.length === 0) { - return yield* taskError("Schedule task was deleted or replaced.", { taskId: input.id }); - } - yield* notifyChanged; - return { task: yield* loadTask(input.id) }; - }); + Effect.mapError((cause) => + taskError("Could not rotate webhook token.", { taskId: input.id, cause }), + ), + ); + if (updated.length === 0) { + return yield* taskError("Schedule task was deleted or replaced.", { taskId: input.id }); + } + yield* notifyChanged; + return { task: yield* loadTask(input.id) }; + }); const deliveryHeaders = (row: WebhookDeliveryRow): Readonly> => Option.getOrElse(decodeHeadersJson(row.headers_json), () => ({})); @@ -1171,31 +1181,32 @@ export const layer = Layer.effect( ), ); - const getWebhookDelivery: ScheduledTaskService["Service"]["getWebhookDelivery"] = (input) => - Effect.gen(function* () { - const rows = yield* sql` + const getWebhookDelivery: ScheduledTaskService["Service"]["getWebhookDelivery"] = Effect.fn( + "ScheduledTaskService.getWebhookDelivery", + )(function* (input) { + const rows = yield* sql` SELECT * FROM scheduled_task_webhook_deliveries WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} `.pipe( - Effect.mapError((cause) => - taskError("Could not load webhook delivery.", { taskId: input.id, cause }), - ), - ); - const row = rows[0]; - if (row === undefined) { - return yield* taskError("Webhook delivery not found.", { taskId: input.id }); - } - return { - delivery: { - ...decodeDeliverySummary(row), - query: row.query, - headers: deliveryHeaders(row), - body: row.body, - bodyTruncated: row.body_truncated === 1, - renderedPrompt: row.rendered_prompt, - }, - }; - }); + Effect.mapError((cause) => + taskError("Could not load webhook delivery.", { taskId: input.id, cause }), + ), + ); + const row = rows[0]; + if (row === undefined) { + return yield* taskError("Webhook delivery not found.", { taskId: input.id }); + } + return { + delivery: { + ...decodeDeliverySummary(row), + query: row.query, + headers: deliveryHeaders(row), + body: row.body, + bodyTruncated: row.body_truncated === 1, + renderedPrompt: row.rendered_prompt, + }, + }; + }); const recordDelivery = (input: { readonly id: string; @@ -1298,43 +1309,44 @@ export const layer = Layer.effect( }, ); - const webhookPermit = (id: ScheduledTaskId) => - Effect.gen(function* () { - const existing = (yield* Ref.get(webhookPermits)).get(id); - if (existing !== undefined) return existing; - const created = yield* Semaphore.make(1); - return yield* Ref.modify(webhookPermits, (permits) => { - const raced = permits.get(id); - return raced === undefined - ? [created, new Map(permits).set(id, created)] - : [raced, permits]; - }); + const webhookPermit = Effect.fn("ScheduledTaskService.webhookPermit")(function* ( + id: ScheduledTaskId, + ) { + const existing = (yield* Ref.get(webhookPermits)).get(id); + if (existing !== undefined) return existing; + const created = yield* Semaphore.make(1); + return yield* Ref.modify(webhookPermits, (permits) => { + const raced = permits.get(id); + return raced === undefined + ? [created, new Map(permits).set(id, created)] + : [raced, permits]; }); + }); const serviceScope = yield* Effect.scope; - const observeDelivery = ( + // Delivery attributes belong to the enclosing triggerWebhook span. + const observeDelivery = Effect.fnUntraced(function* ( request: WebhookTriggerRequest, outcome: WebhookDeliveryOutcome, receivedAt: DateTime.DateTime | undefined, now: DateTime.DateTime | undefined, - ) => - Effect.gen(function* () { - const source = request.relayDeliveryId === undefined ? "direct" : "relay"; - yield* Effect.annotateCurrentSpan({ - "scheduled_task.webhook.outcome": outcome, - "scheduled_task.webhook.source": source, - }); - yield* Metrics.increment(Metrics.webhookDeliveriesTotal, { outcome, source }); - if (request.receivedAt !== undefined && receivedAt !== undefined && now !== undefined) { - const heldMs = Math.max( - 0, - DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt), - ); - yield* Effect.annotateCurrentSpan({ "scheduled_task.webhook.held_ms": heldMs }); - yield* Metric.update(Metrics.webhookHeldDelay, Duration.millis(heldMs)); - } + ) { + const source = request.relayDeliveryId === undefined ? "direct" : "relay"; + yield* Effect.annotateCurrentSpan({ + "scheduled_task.webhook.outcome": outcome, + "scheduled_task.webhook.source": source, }); + yield* Metrics.increment(Metrics.webhookDeliveriesTotal, { outcome, source }); + if (request.receivedAt !== undefined && receivedAt !== undefined && now !== undefined) { + const heldMs = Math.max( + 0, + DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt), + ); + yield* Effect.annotateCurrentSpan({ "scheduled_task.webhook.held_ms": heldMs }); + yield* Metric.update(Metrics.webhookHeldDelay, Duration.millis(heldMs)); + } + }); const triggerWebhook: ScheduledTaskService["Service"]["triggerWebhook"] = (request) => triggerWebhookUnobserved(request).pipe( @@ -1350,252 +1362,250 @@ export const layer = Layer.effect( }), ); - const triggerWebhookUnobserved = (request: WebhookTriggerRequest) => - Effect.gen(function* () { - const taskId = decodeTaskId(request.hookId); - const notFound = observeDelivery(request, "not_found", undefined, undefined).pipe( - Effect.as({ _tag: "not_found" as const }), - ); - if (Option.isNone(taskId)) return yield* notFound; - const rows = yield* getRows(taskId.value).pipe( - Effect.mapError((cause) => - taskError("Could not load schedule task.", { taskId: taskId.value, cause }), - ), - ); - const row = rows[0]; - // A wrong token is indistinguishable from an unknown hook, so the URL - // does not reveal which hooks exist. - if ( - row === undefined || - row.webhook_token === null || - !constantTimeEquals(request.token, row.webhook_token) - ) { - return yield* notFound; - } - const task = yield* decodeRow(row); - yield* Effect.annotateCurrentSpan({ "scheduled_task.id": task.id }); - const schedule = task.schedule; - if (schedule.type !== "webhook") return yield* notFound; + // triggerWebhook owns the operation span and its task-id annotation. + const triggerWebhookUnobserved = Effect.fnUntraced(function* (request: WebhookTriggerRequest) { + const taskId = decodeTaskId(request.hookId); + const notFound = observeDelivery(request, "not_found", undefined, undefined).pipe( + Effect.as({ _tag: "not_found" as const }), + ); + if (Option.isNone(taskId)) return yield* notFound; + const rows = yield* getRows(taskId.value).pipe( + Effect.mapError((cause) => + taskError("Could not load schedule task.", { taskId: taskId.value, cause }), + ), + ); + const row = rows[0]; + // A wrong token is indistinguishable from an unknown hook, so the URL + // does not reveal which hooks exist. + if ( + row === undefined || + row.webhook_token === null || + !constantTimeEquals(request.token, row.webhook_token) + ) { + return yield* notFound; + } + const task = yield* decodeRow(row); + yield* Effect.annotateCurrentSpan({ "scheduled_task.id": task.id }); + const schedule = task.schedule; + if (schedule.type !== "webhook") return yield* notFound; - const now = yield* localNow; - // Anyone can reach the tunnel directly and set the relay's header, so - // a receive time is never later than now: a future one would pin the - // delivery in the log and slip past the task's max age. - const receivedAt = - request.receivedAt === undefined - ? now - : DateTime.min( - Option.getOrElse(DateTime.make(request.receivedAt), () => now), - now, - ); - const observe = (outcome: WebhookDeliveryOutcome) => - observeDelivery(request, outcome, receivedAt, now); - const deliveryId = ScheduledTaskWebhookDeliveryId.make( - request.relayDeliveryId === undefined - ? `delivery:${yield* crypto - .randomUUIDv4() - .pipe( - Effect.mapError((cause) => - taskError("Could not generate delivery id.", { cause }), - ), - )}` - : `delivery:relay:${request.relayDeliveryId}`, - ); - // Claimed in its own table, kept longer than the relay holds a request, - // because the delivery log is trimmed. A rate-limited delivery stays - // held on the relay, so it must give up its claim or the next pass - // would treat it as already delivered. - let claimAcquired = false; - const releaseClaim = (result: A) => - !claimAcquired || request.relayDeliveryId === undefined - ? Effect.succeed(result) - : sql` + const now = yield* localNow; + // Anyone can reach the tunnel directly and set the relay's header, so + // a receive time is never later than now: a future one would pin the + // delivery in the log and slip past the task's max age. + const receivedAt = + request.receivedAt === undefined + ? now + : DateTime.min( + Option.getOrElse(DateTime.make(request.receivedAt), () => now), + now, + ); + const observe = (outcome: WebhookDeliveryOutcome) => + observeDelivery(request, outcome, receivedAt, now); + const deliveryId = ScheduledTaskWebhookDeliveryId.make( + request.relayDeliveryId === undefined + ? `delivery:${yield* crypto + .randomUUIDv4() + .pipe( + Effect.mapError((cause) => taskError("Could not generate delivery id.", { cause })), + )}` + : `delivery:relay:${request.relayDeliveryId}`, + ); + // Claimed in its own table, kept longer than the relay holds a request, + // because the delivery log is trimmed. A rate-limited delivery stays + // held on the relay, so it must give up its claim or the next pass + // would treat it as already delivered. + let claimAcquired = false; + const releaseClaim = (result: A) => + !claimAcquired || request.relayDeliveryId === undefined + ? Effect.succeed(result) + : sql` DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = ${request.relayDeliveryId} `.pipe(Effect.ignore, Effect.as(result)); - // From the claim until the run is forked nothing may interrupt: a - // request dropped in between (the relay hangs up after its timeout) - // would leave a claimed delivery that never runs, or a queue slot that - // is never released. - return yield* Effect.uninterruptible( - Effect.gen(function* () { - if (request.relayDeliveryId !== undefined) { - const claimed = yield* sql<{ relay_delivery_id: string }>` + // From the claim until the run is forked nothing may interrupt: a + // request dropped in between (the relay hangs up after its timeout) + // would leave a claimed delivery that never runs, or a queue slot that + // is never released. + return yield* Effect.uninterruptible( + Effect.gen(function* () { + if (request.relayDeliveryId !== undefined) { + const claimed = yield* sql<{ relay_delivery_id: string }>` INSERT INTO scheduled_task_webhook_relay_deliveries (relay_delivery_id, task_id, seen_at) VALUES (${request.relayDeliveryId}, ${task.id}, ${iso(now)}) ON CONFLICT (relay_delivery_id) DO NOTHING RETURNING relay_delivery_id `.pipe( - Effect.mapError((cause) => - taskError("Could not record webhook delivery.", { taskId: task.id, cause }), - ), - ); - if (claimed.length === 0) { - yield* observe("duplicate"); - return { _tag: "accepted" as const, deliveryId, outcome: "duplicate" as const }; - } - claimAcquired = true; - yield* sql` + Effect.mapError((cause) => + taskError("Could not record webhook delivery.", { taskId: task.id, cause }), + ), + ); + if (claimed.length === 0) { + yield* observe("duplicate"); + return { _tag: "accepted" as const, deliveryId, outcome: "duplicate" as const }; + } + claimAcquired = true; + yield* sql` DELETE FROM scheduled_task_webhook_relay_deliveries WHERE seen_at < ${iso(DateTime.subtract(now, { hours: 48 }))} `.pipe(Effect.ignore); - } - const log = ( - outcome: ScheduledTaskWebhookDeliveryOutcome, - details: { - readonly signatureVerified?: boolean; - readonly missing?: ReadonlyArray; - readonly renderedPrompt?: string; - readonly error?: string; - } = {}, - ) => - recordDelivery({ - id: deliveryId, - taskId: task.id, - receivedAt: iso(receivedAt), - request, - outcome, - signatureVerified: details.signatureVerified ?? false, - missing: details.missing ?? [], - renderedPrompt: details.renderedPrompt ?? null, - ...(details.error === undefined ? {} : { error: details.error }), - }); - - // Only the first rejected request in a window is logged, so a flood - // cannot write rows or push the real deliveries out of the log. - const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(now)); - if (slot !== "allowed") { - if (slot === "first_rejected") yield* log("rate_limited"); - yield* observe("rate_limited"); - return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "rate_limited" as const, - }); - } - if (!task.enabled) { - yield* log("disabled"); - yield* observe("disabled"); - return { _tag: "disabled" as const }; - } - const signature = schedule.signature; - if (signature !== null) { - const verified = - row.webhook_secret !== null && - verifyWebhookSignature({ - signature, - secret: row.webhook_secret, - headers: request.headers, - body: request.body, - }); - if (!verified) { - yield* log("rejected_signature"); - yield* observe("rejected_signature"); - return { _tag: "rejected_signature" as const }; - } - } - const maxAgeMinutes = schedule.maxDeliveryAgeMinutes ?? null; - if ( - maxAgeMinutes !== null && - DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt) > - maxAgeMinutes * 60_000 - ) { - yield* log("expired", { signatureVerified: signature !== null }); - yield* observe("expired"); - return { _tag: "expired" as const }; - } + } + const log = ( + outcome: ScheduledTaskWebhookDeliveryOutcome, + details: { + readonly signatureVerified?: boolean; + readonly missing?: ReadonlyArray; + readonly renderedPrompt?: string; + readonly error?: string; + } = {}, + ) => + recordDelivery({ + id: deliveryId, + taskId: task.id, + receivedAt: iso(receivedAt), + request, + outcome, + signatureVerified: details.signatureVerified ?? false, + missing: details.missing ?? [], + renderedPrompt: details.renderedPrompt ?? null, + ...(details.error === undefined ? {} : { error: details.error }), + }); - const frameId = yield* crypto.randomBytes(6).pipe( - Effect.map((bytes) => Buffer.from(bytes).toString("hex")), - Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), - ); - const rendered = renderWebhookPrompt(task.prompt, request, frameId); - // Providers trim the prompt before checking, so whitespace around - // it is free. - if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { - yield* log("dispatch_failed", { - signatureVerified: signature !== null, - missing: rendered.missing, - renderedPrompt: rendered.prompt, - error: "The filled-in prompt is too long.", - }); - yield* observe("prompt_too_long"); - return { _tag: "accepted" as const, deliveryId, outcome: "prompt_too_long" as const }; - } - // Bound the deliveries one task holds, so steady traffic to a stuck - // task cannot pile up parked fibers. - const queueKey = `${task.id}\u0000${task.createdAt}`; - const queued = yield* Ref.modify(webhookQueued, (counts) => { - const count = counts.get(queueKey) ?? 0; - return count >= WEBHOOK_MAX_QUEUED_PER_TASK - ? ([false, counts] as const) - : ([true, new Map(counts).set(queueKey, count + 1)] as const); + // Only the first rejected request in a window is logged, so a flood + // cannot write rows or push the real deliveries out of the log. + const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(now)); + if (slot !== "allowed") { + if (slot === "first_rejected") yield* log("rate_limited"); + yield* observe("rate_limited"); + return yield* releaseClaim({ + _tag: "rate_limited" as const, + outcome: "rate_limited" as const, }); - if (!queued) { - yield* observe("queue_full"); - return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "queue_full" as const, + } + if (!task.enabled) { + yield* log("disabled"); + yield* observe("disabled"); + return { _tag: "disabled" as const }; + } + const signature = schedule.signature; + if (signature !== null) { + const verified = + row.webhook_secret !== null && + verifyWebhookSignature({ + signature, + secret: row.webhook_secret, + headers: request.headers, + body: request.body, }); + if (!verified) { + yield* log("rejected_signature"); + yield* observe("rejected_signature"); + return { _tag: "rejected_signature" as const }; } - const release = Ref.update(webhookQueued, (counts) => { - const next = new Map(counts); - const count = (next.get(queueKey) ?? 1) - 1; - if (count <= 0) next.delete(queueKey); - else next.set(queueKey, count); - return next; - }); - yield* log("accepted", { + } + const maxAgeMinutes = schedule.maxDeliveryAgeMinutes ?? null; + if ( + maxAgeMinutes !== null && + DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt) > + maxAgeMinutes * 60_000 + ) { + yield* log("expired", { signatureVerified: signature !== null }); + yield* observe("expired"); + return { _tag: "expired" as const }; + } + + const frameId = yield* crypto.randomBytes(6).pipe( + Effect.map((bytes) => Buffer.from(bytes).toString("hex")), + Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), + ); + const rendered = renderWebhookPrompt(task.prompt, request, frameId); + // Providers trim the prompt before checking, so whitespace around + // it is free. + if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* log("dispatch_failed", { signatureVerified: signature !== null, missing: rendered.missing, renderedPrompt: rendered.prompt, - }).pipe(Effect.onError(() => release)); - yield* observe("accepted"); - const permit = yield* webhookPermit(task.id); - const runOutcome = (outcome: "started" | "skipped" | "failed") => - Effect.all([ - Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), - Metrics.increment(Metrics.webhookRunsTotal, { outcome }), - ]); - yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe( - Effect.flatMap((completed) => - completed.lastRunStatus === "failed" - ? runOutcome("failed").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ) - : runOutcome("started"), - ), - Effect.catchTags({ - WebhookDeliverySkipped: (skipped) => - runOutcome("skipped").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), - ), - }), - // The log is readable over RPC, so it gets a fixed reason; the - // cause, which can carry request data, stays in the server log. - Effect.catchCause((cause) => - Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe( - Effect.andThen(runOutcome("failed")), - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), + error: "The filled-in prompt is too long.", + }); + yield* observe("prompt_too_long"); + return { _tag: "accepted" as const, deliveryId, outcome: "prompt_too_long" as const }; + } + // Bound the deliveries one task holds, so steady traffic to a stuck + // task cannot pile up parked fibers. + const queueKey = `${task.id}\u0000${task.createdAt}`; + const queued = yield* Ref.modify(webhookQueued, (counts) => { + const count = counts.get(queueKey) ?? 0; + return count >= WEBHOOK_MAX_QUEUED_PER_TASK + ? ([false, counts] as const) + : ([true, new Map(counts).set(queueKey, count + 1)] as const); + }); + if (!queued) { + yield* observe("queue_full"); + return yield* releaseClaim({ + _tag: "rate_limited" as const, + outcome: "queue_full" as const, + }); + } + const release = Ref.update(webhookQueued, (counts) => { + const next = new Map(counts); + const count = (next.get(queueKey) ?? 1) - 1; + if (count <= 0) next.delete(queueKey); + else next.set(queueKey, count); + return next; + }); + yield* log("accepted", { + signatureVerified: signature !== null, + missing: rendered.missing, + renderedPrompt: rendered.prompt, + }).pipe(Effect.onError(() => release)); + yield* observe("accepted"); + const permit = yield* webhookPermit(task.id); + const runOutcome = (outcome: "started" | "skipped" | "failed") => + Effect.all([ + Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), + Metrics.increment(Metrics.webhookRunsTotal, { outcome }), + ]); + yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe( + Effect.flatMap((completed) => + completed.lastRunStatus === "failed" + ? runOutcome("failed").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), + ) + : runOutcome("started"), + ), + Effect.catchTags({ + WebhookDeliverySkipped: (skipped) => + runOutcome("skipped").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), ), + }), + // The log is readable over RPC, so it gets a fixed reason; the + // cause, which can carry request data, stays in the server log. + Effect.catchCause((cause) => + Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe( + Effect.andThen(runOutcome("failed")), + Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), ), - permit.withPermits(1), - Effect.withSpan("ScheduledTaskService.runWebhookDelivery", { - root: true, - attributes: { - "scheduled_task.id": task.id, - "scheduled_task.webhook.delivery_id": deliveryId, - }, - }), - Effect.ensuring(release), - Effect.forkIn(serviceScope), - ); - // Dispatch owns the accepted delivery now; retries must keep its claim. - claimAcquired = false; - return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const }; - }).pipe(Effect.onError(() => releaseClaim(undefined))), - ); - }); + ), + permit.withPermits(1), + Effect.withSpan("ScheduledTaskService.runWebhookDelivery", { + root: true, + attributes: { + "scheduled_task.id": task.id, + "scheduled_task.webhook.delivery_id": deliveryId, + }, + }), + Effect.ensuring(release), + Effect.forkIn(serviceScope), + ); + // Dispatch owns the accepted delivery now; retries must keep its claim. + claimAcquired = false; + return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const }; + }).pipe(Effect.onError(() => releaseClaim(undefined))), + ); + }); return ScheduledTaskService.of({ list, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 41a2cc119352..ae8594a03f0a 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -15,6 +15,7 @@ import * as EffectScheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -881,6 +882,33 @@ const deliveriesCounted = (outcome: string, source: "relay" | "direct") => }), ); +it.effect("keeps delivery attributes on the webhook operation span", () => + withService(({ service }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); + const spans: Tracer.NativeSpan[] = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const receivedAt = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { seconds: 1 })); + yield* service + .triggerWebhook(requestFor(task, { receivedAt, relayDeliveryId: "traced-delivery" })) + .pipe(Effect.withTracer(tracer)); + + const span = spans.find((entry) => entry.name === "ScheduledTaskService.triggerWebhook"); + assert.isDefined(span); + assert.equal(span?.attributes.get("scheduled_task.id"), task.id); + assert.equal(span?.attributes.get("scheduled_task.webhook.outcome"), "disabled"); + assert.equal(span?.attributes.get("scheduled_task.webhook.source"), "relay"); + assert.equal(span?.attributes.get("scheduled_task.webhook.held_ms"), 1_000); + }), + ), +); + it.effect("counts each handled request by what happened to it", () => withService(({ service, launches }) => Effect.gen(function* () { diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index aa1b25a6351c..ce67fa157d33 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -33,98 +33,98 @@ export const layer = HttpApiBuilder.group( * The endpoint is raw so the signature is checked over the exact body * bytes; the service checks the token and signature. */ - const handler = ({ + // Only verified relay deliveries join a trace; direct requests keep their existing context. + const handler = Effect.fnUntraced(function* ({ params, request, }: { readonly params: { readonly hookId: string; readonly token: string }; readonly request: HttpServerRequest.HttpServerRequest; - }) => - Effect.gen(function* () { - const contentLength = Number(request.headers["content-length"] ?? "0"); - if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) { - return json(413, { error: "body_too_large" }, "body_too_large"); - } - // Chunked requests carry no content-length, so the reader itself is capped. - const body = yield* request.arrayBuffer.pipe( - Effect.map((buffer) => new Uint8Array(buffer)), - Effect.provideService( - HttpIncomingMessage.MaxBodySize, - ByteSize.bytes(WEBHOOK_MAX_BODY_BYTES), - ), - Effect.option, - ); - const tooLarge = (error: string) => - Metrics.increment(Metrics.webhookDeliveriesTotal, { - outcome: "body_too_large", - source: request.headers["x-t3-relay-delivery-id"] ? "relay" : "direct", - }).pipe(Effect.as(json(413, { error }, "body_too_large"))); - if (Option.isNone(body)) return yield* tooLarge("body_too_large_or_unreadable"); - if (body.value.byteLength > WEBHOOK_MAX_BODY_BYTES) { - return yield* tooLarge("body_too_large"); - } + }) { + const contentLength = Number(request.headers["content-length"] ?? "0"); + if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) { + return json(413, { error: "body_too_large" }, "body_too_large"); + } + // Chunked requests carry no content-length, so the reader itself is capped. + const body = yield* request.arrayBuffer.pipe( + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.provideService( + HttpIncomingMessage.MaxBodySize, + ByteSize.bytes(WEBHOOK_MAX_BODY_BYTES), + ), + Effect.option, + ); + const tooLarge = (error: string) => + Metrics.increment(Metrics.webhookDeliveriesTotal, { + outcome: "body_too_large", + source: request.headers["x-t3-relay-delivery-id"] ? "relay" : "direct", + }).pipe(Effect.as(json(413, { error }, "body_too_large"))); + if (Option.isNone(body)) return yield* tooLarge("body_too_large_or_unreadable"); + if (body.value.byteLength > WEBHOOK_MAX_BODY_BYTES) { + return yield* tooLarge("body_too_large"); + } - const headers: Record = {}; - for (const [name, value] of Object.entries(request.headers)) { - if (typeof value === "string") headers[name.toLowerCase()] = value; - } - const queryIndex = request.url.indexOf("?"); - // The relay's delivery id and receive time count only with its signed - // proof; the URL can also be called directly. The receive time matters - // for requests the relay held while we were offline. - const relay = yield* relayDeliveryProof.verify({ headers, hookId: params.hookId }); - const relayDeliveryId = Option.isSome(relay) ? relay.value.deliveryId : undefined; - const relayReceivedAt = Option.isSome(relay) ? relay.value.receivedAt : undefined; + const headers: Record = {}; + for (const [name, value] of Object.entries(request.headers)) { + if (typeof value === "string") headers[name.toLowerCase()] = value; + } + const queryIndex = request.url.indexOf("?"); + // The relay's delivery id and receive time count only with its signed + // proof; the URL can also be called directly. The receive time matters + // for requests the relay held while we were offline. + const relay = yield* relayDeliveryProof.verify({ headers, hookId: params.hookId }); + const relayDeliveryId = Option.isSome(relay) ? relay.value.deliveryId : undefined; + const relayReceivedAt = Option.isSome(relay) ? relay.value.receivedAt : undefined; - // A relay delivery joins the relay's trace, and goes to the T3 Connect - // tracer with it. Anyone else's traceparent is never trusted. - const relayParent = Option.isSome(relay) - ? HttpTraceContext.fromHeaders(request.headers) - : Option.none(); - const result = yield* scheduledTasks - .triggerWebhook({ - hookId: params.hookId, - token: params.token, - method: request.method, - path: `${ScheduledTaskService.WEBHOOK_ROUTE_PREFIX}/${encodeURIComponent(params.hookId)}`, - query: queryIndex === -1 ? "" : request.url.slice(queryIndex + 1), - headers, - body: body.value, - bodyText: new TextDecoder().decode(body.value), - ...(relayDeliveryId ? { relayDeliveryId } : {}), - ...(relayReceivedAt ? { receivedAt: relayReceivedAt } : {}), - }) - .pipe( - Option.isSome(relayParent) - ? (effect) => - effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing) - : (effect) => effect, - Effect.catchCause((cause) => - Effect.logWarning("Webhook delivery failed").pipe( - Effect.annotateLogs({ hookId: params.hookId }), - Effect.andThen(Effect.logDebug("Webhook delivery failure cause", { cause })), - Effect.as({ _tag: "error" as const }), - ), + // A relay delivery joins the relay's trace, and goes to the T3 Connect + // tracer with it. Anyone else's traceparent is never trusted. + const relayParent = Option.isSome(relay) + ? HttpTraceContext.fromHeaders(request.headers) + : Option.none(); + const result = yield* scheduledTasks + .triggerWebhook({ + hookId: params.hookId, + token: params.token, + method: request.method, + path: `${ScheduledTaskService.WEBHOOK_ROUTE_PREFIX}/${encodeURIComponent(params.hookId)}`, + query: queryIndex === -1 ? "" : request.url.slice(queryIndex + 1), + headers, + body: body.value, + bodyText: new TextDecoder().decode(body.value), + ...(relayDeliveryId ? { relayDeliveryId } : {}), + ...(relayReceivedAt ? { receivedAt: relayReceivedAt } : {}), + }) + .pipe( + Option.isSome(relayParent) + ? (effect) => + effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing) + : (effect) => effect, + Effect.catchCause((cause) => + Effect.logWarning("Webhook delivery failed").pipe( + Effect.annotateLogs({ hookId: params.hookId }), + Effect.andThen(Effect.logDebug("Webhook delivery failure cause", { cause })), + Effect.as({ _tag: "error" as const }), ), - ); + ), + ); - switch (result._tag) { - case "accepted": - return json(202, { deliveryId: result.deliveryId }, result.outcome); - case "not_found": - return json(404, { error: "hook_not_found" }, "not_found"); - case "rejected_signature": - return json(401, { error: "invalid_signature" }, "rejected_signature"); - case "disabled": - return json(409, { error: "hook_disabled" }, "disabled"); - case "rate_limited": - return json(429, { error: "rate_limited" }, result.outcome); - case "expired": - return json(410, { error: "delivery_too_old" }, "expired"); - case "error": - return json(500, { error: "internal_error" }, "error"); - } - }); + switch (result._tag) { + case "accepted": + return json(202, { deliveryId: result.deliveryId }, result.outcome); + case "not_found": + return json(404, { error: "hook_not_found" }, "not_found"); + case "rejected_signature": + return json(401, { error: "invalid_signature" }, "rejected_signature"); + case "disabled": + return json(409, { error: "hook_disabled" }, "disabled"); + case "rate_limited": + return json(429, { error: "rate_limited" }, result.outcome); + case "expired": + return json(410, { error: "delivery_too_old" }, "expired"); + case "error": + return json(500, { error: "internal_error" }, "error"); + } + }); return handlers .handleRaw("webhookPost", handler) .handleRaw("webhookPut", handler) diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index fadad6ea743b..4470c257f67b 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -73,8 +73,8 @@ const make = Effect.gen(function* () { yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie), ).toString("hex"); - const answer: SecretRequests["Service"]["answer"] = (input) => - Effect.gen(function* () { + const answer: SecretRequests["Service"]["answer"] = Effect.fn("SecretRequests.answer")( + function* (input) { yield* Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId, "secret_request.answer": input.answer.type, @@ -159,14 +159,16 @@ const make = Effect.gen(function* () { yield* removeLogged(storeName(refFor(salt, input.threadId, item.id))); return yield* new SecretRequestError({ reason: "agent_stopped" }); } - }).pipe(Effect.withSpan("SecretRequests.answer")); + }, + ); - const savedRef: SecretRequests["Service"]["savedRef"] = (input) => - Effect.gen(function* () { + const savedRef: SecretRequests["Service"]["savedRef"] = Effect.fn("SecretRequests.savedRef")( + function* (input) { const ref = refFor(salt, input.threadId, input.turnItemId); const stored = yield* store.get(storeName(ref)).pipe(Effect.orElseSucceed(Option.none)); return Option.map(stored, () => ref); - }); + }, + ); const removeLogged = (name: string) => store.remove(name).pipe( @@ -191,30 +193,32 @@ const make = Effect.gen(function* () { Effect.withSpan("SecretRequests.consume"), ); - const consumeRef = (input: { readonly ref: SecretRef; readonly projectId: ProjectId }) => - Effect.gen(function* () { - if (!REF_PATTERN.test(input.ref)) - return yield* new SecretRequestError({ reason: "invalid_ref" }); - const stored = yield* store - .get(storeName(input.ref)) - .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "read_failed", cause }))); - const decoded = Option.flatMap(stored, (bytes) => - decodeStored(new TextDecoder().decode(bytes)), - ); - if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) { - return yield* new SecretRequestError({ reason: "ref_unavailable" }); - } - if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) { - yield* removeLogged(storeName(input.ref)); - return yield* new SecretRequestError({ reason: "ref_expired" }); - } - // One use: the value moves into whatever consumed it. If it cannot be - // deleted, it is not handed out, so a ref is never used twice. - if (!(yield* removeLogged(storeName(input.ref)))) { - return yield* new SecretRequestError({ reason: "consume_failed" }); - } - return decoded.value.value; - }); + const consumeRef = Effect.fn("SecretRequests.consumeRef")(function* (input: { + readonly ref: SecretRef; + readonly projectId: ProjectId; + }) { + if (!REF_PATTERN.test(input.ref)) + return yield* new SecretRequestError({ reason: "invalid_ref" }); + const stored = yield* store + .get(storeName(input.ref)) + .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "read_failed", cause }))); + const decoded = Option.flatMap(stored, (bytes) => + decodeStored(new TextDecoder().decode(bytes)), + ); + if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) { + return yield* new SecretRequestError({ reason: "ref_unavailable" }); + } + if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) { + yield* removeLogged(storeName(input.ref)); + return yield* new SecretRequestError({ reason: "ref_expired" }); + } + // One use: the value moves into whatever consumed it. If it cannot be + // deleted, it is not handed out, so a ref is never used twice. + if (!(yield* removeLogged(storeName(input.ref)))) { + return yield* new SecretRequestError({ reason: "consume_failed" }); + } + return decoded.value.value; + }); const sweepExpired = Effect.gen(function* () { if (store.directory === undefined) return; diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts index c43cd259ef66..2945951d847a 100644 --- a/apps/server/src/serverActivation.ts +++ b/apps/server/src/serverActivation.ts @@ -9,21 +9,20 @@ export class ServerActivation extends Context.Reference | un { defaultValue: () => undefined }, ) {} -export const forkParkedFiber = ( +export const forkParkedFiber = Effect.fn("serverActivation.forkParkedFiber")(function* ( effect: Effect.Effect, -): Effect.Effect, never, Scope.Scope | R> => - Effect.gen(function* () { - const activation = yield* ServerActivation; - if (activation === undefined) { - return yield* Effect.forkScoped(effect); - } - const parked = yield* Deferred.make(); - const fiber = yield* Effect.forkScoped( - Deferred.succeed(parked, undefined).pipe(Effect.andThen(activation), Effect.andThen(effect)), - ); - yield* Deferred.await(parked); - return fiber; - }); +): Effect.fn.Return, never, Scope.Scope | R> { + const activation = yield* ServerActivation; + if (activation === undefined) { + return yield* Effect.forkScoped(effect); + } + const parked = yield* Deferred.make(); + const fiber = yield* Effect.forkScoped( + Deferred.succeed(parked, undefined).pipe(Effect.andThen(activation), Effect.andThen(effect)), + ); + yield* Deferred.await(parked); + return fiber; +}); export const forkParked = ( effect: Effect.Effect, diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 140020614fd7..abc16c16016b 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -85,30 +85,31 @@ export const persistServerRuntimeState = (input: { ), ); -export const clearPersistedServerRuntimeState = (path: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(path, { force: true }).pipe( - Effect.mapError( - (cause) => - new ServerRuntimeStateError({ - operation: "clear", - statePath: path, - cause, +export const clearPersistedServerRuntimeState = Effect.fn( + "serverRuntimeState.clearPersistedServerRuntimeState", +)(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { force: true }).pipe( + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "clear", + statePath: path, + cause, + }), + ), + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, }), - ), - Effect.catchTags({ - ServerRuntimeStateError: (error) => - Effect.logWarning(error.message).pipe( - Effect.annotateLogs({ - operation: error.operation, - statePath: error.statePath, - cause: error, - }), - ), - }), - ); - }); + ), + }), + ); +}); /** * Signal 0 delivers nothing; it only reports whether the pid exists. EPERM @@ -124,8 +125,10 @@ export const isProcessAlive = (pid: number): boolean => { } }; -export const readPersistedServerRuntimeState = (path: string) => - Effect.gen(function* () { +export const readPersistedServerRuntimeState = Effect.fn( + "serverRuntimeState.readPersistedServerRuntimeState", +)( + function* (path: string) { const fs = yield* FileSystem.FileSystem; const raw = yield* fs.readFileString(path).pipe( Effect.matchEffect({ @@ -162,16 +165,16 @@ export const readPersistedServerRuntimeState = (path: string) => }), ), ); - }).pipe( - Effect.catchTags({ - ServerRuntimeStateError: (error) => - Effect.logWarning(error.message).pipe( - Effect.annotateLogs({ - operation: error.operation, - statePath: error.statePath, - cause: error, - }), - Effect.as(Option.none()), - ), - }), - ); + }, + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + Effect.as(Option.none()), + ), + }), +); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 8c4d70758c4d..5289b7b4d75b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -203,62 +203,59 @@ export class ServerSettingsService extends Context.Service< static readonly layerTest = (overrides: DeepPartial = {}) => layerTest(overrides); } -const makeTest = (overrides: DeepPartial = {}) => - Effect.gen(function* () { - const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = - overrides; - const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); - const initialSettings = yield* normalizeServerSettings({ - ...merged, - ...(automaticGitFetchInterval !== undefined - ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } - : {}), - ...(providerHealthRefreshInterval !== undefined - ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } - : {}), - }); - const currentSettingsRef = yield* Ref.make(initialSettings); - const writeSemaphore = yield* Semaphore.make(1); - const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)); - - const updateTestSettings = ( - update: (current: ServerSettings) => Effect.Effect, - ): Effect.Effect => - writeSemaphore.withPermits(1)( - Ref.get(currentSettingsRef).pipe( - Effect.flatMap(update), - Effect.flatMap(normalizeServerSettings), - Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), - Effect.map(resolveTextGenerationProvider), - ), - ); - - return { - start: Effect.void, - ready: Effect.void, - getSettings, - updateSettings: (patch) => - updateTestSettings((currentSettings) => - Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), - ), - updateProviderInstance: (mutation, patch = {}) => - updateTestSettings((currentSettings) => - Effect.gen(function* () { - yield* ensureProviderInstanceMutationAllowed( - currentSettings, - mutation, - "test settings", - ); - const patched = applyServerSettingsPatch(currentSettings, patch); - return applyProviderInstanceMutation(patched, mutation); - }), - ), - withSettingsSnapshot: (use) => - writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))), - streamChanges: Stream.empty, - subscribeChanges: Effect.succeed(Stream.empty), - } satisfies ServerSettingsService["Service"]; +const makeTest = Effect.fn("serverSettings.makeTest")(function* ( + overrides: DeepPartial = {}, +) { + const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = + overrides; + const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); + const initialSettings = yield* normalizeServerSettings({ + ...merged, + ...(automaticGitFetchInterval !== undefined + ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } + : {}), + ...(providerHealthRefreshInterval !== undefined + ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } + : {}), }); + const currentSettingsRef = yield* Ref.make(initialSettings); + const writeSemaphore = yield* Semaphore.make(1); + const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)); + + const updateTestSettings = ( + update: (current: ServerSettings) => Effect.Effect, + ): Effect.Effect => + writeSemaphore.withPermits(1)( + Ref.get(currentSettingsRef).pipe( + Effect.flatMap(update), + Effect.flatMap(normalizeServerSettings), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.map(resolveTextGenerationProvider), + ), + ); + + return { + start: Effect.void, + ready: Effect.void, + getSettings, + updateSettings: (patch) => + updateTestSettings((currentSettings) => + Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), + ), + updateProviderInstance: (mutation, patch = {}) => + updateTestSettings((currentSettings) => + Effect.gen(function* () { + yield* ensureProviderInstanceMutationAllowed(currentSettings, mutation, "test settings"); + const patched = applyServerSettingsPatch(currentSettings, patch); + return applyProviderInstanceMutation(patched, mutation); + }), + ), + withSettingsSnapshot: (use) => + writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + } satisfies ServerSettingsService["Service"]; +}); export const layerTest = (overrides: DeepPartial = {}) => Layer.effect(ServerSettingsService, makeTest(overrides)); @@ -625,8 +622,8 @@ const make = Effect.gen(function* () { ), ); - const moveInlineSettingsSecrets = (settings: ServerSettings) => - Effect.gen(function* () { + const moveInlineSettingsSecrets = Effect.fn("serverSettings.moveInlineSettingsSecrets")( + function* (settings: ServerSettings) { const pending: SettingsSecret[] = []; mapSettingsSecrets(settings, (secret) => { if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) { @@ -652,7 +649,8 @@ const make = Effect.gen(function* () { return mapSettingsSecrets(settings, (secret) => moved.has(secret.secretName) ? { value: "", redacted: true } : secret, ); - }); + }, + ); const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; @@ -802,10 +800,8 @@ const make = Effect.gen(function* () { Effect.andThen(Cache.get(settingsCache, cacheKey)), ); - const materializeSettingsSecrets = ( - settings: ServerSettings, - ): Effect.Effect => - Effect.gen(function* () { + const materializeSettingsSecrets = Effect.fn("serverSettings.materializeSettingsSecrets")( + function* (settings: ServerSettings): Effect.fn.Return { const storedSecrets = new Map(); const pending = new Map(); mapSettingsSecrets(settings, (secret) => { @@ -837,7 +833,8 @@ const make = Effect.gen(function* () { : secret.value, redacted: secret.sensitive && secret.kind === "environment" && secret.redacted, })); - }); + }, + ); const materializeChanges = (changes: Stream.Stream) => changes.pipe( diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 74d24ffa2117..098f53cc254b 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -247,8 +247,8 @@ const make = Effect.gen(function* () { ); }); - const measureWorktree = (worktreePath: string) => - Effect.gen(function* () { + const measureWorktree = Effect.fn("storageCleanup.measureWorktree")( + function* (worktreePath: string) { const root = yield* fs.realPath(worktreePath); const pending = [root]; let entries = 1; @@ -289,10 +289,10 @@ const make = Effect.gen(function* () { } } return bytes; - }).pipe( - Effect.timeout("30 seconds"), - Effect.orElseSucceed(() => null), - ); + }, + Effect.timeout("30 seconds"), + Effect.orElseSucceed(() => null), + ); // The snapshot takes seconds on large databases, so it runs on the reader // worker instead of the event loop. diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts index e3f50aef6a8d..724a74b5429b 100644 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts +++ b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts @@ -40,8 +40,9 @@ const make = Effect.gen(function* () { // A task can be re-reported with the same final status; count it once per process. const reported = new Set(); - const onSubagent = (event: SubagentEvent) => - Effect.gen(function* () { + // Runs for every subagent update; most return early, so avoid a span per event. + const onSubagent = Effect.fnUntraced( + function* (event: SubagentEvent) { const task = event.payload; if (task.origin !== "app_owned" || !FINAL_STATUSES.has(task.status)) return; if (reported.has(task.id)) return; @@ -79,7 +80,9 @@ const make = Effect.gen(function* () { }); // Only after recording: a failed lookup leaves the next final update to report it. reported.add(task.id); - }).pipe(Effect.ignoreCause); + }, + (effect) => effect.pipe(Effect.ignoreCause), + ); yield* forkParked( Stream.runForEach(orchestrator.streamDomainEvents, (event) => diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 08651591f1a8..858a16aa6f28 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1541,8 +1541,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const workerScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); - const publishEvent = (event: TerminalEvent) => - Effect.gen(function* () { + const publishEvent = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (event: TerminalEvent) { for (const listener of terminalEventListeners) { yield* listener(event).pipe(Effect.ignoreCause({ log: true })); } @@ -3016,84 +3017,85 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); - const restartResolved = (input: TerminalRestartInput) => - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - inputCount: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - closedSessionKeys.delete(sessionKey); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } + const restartResolved = Effect.fn("Manager.restartResolved")(function* ( + input: TerminalRestartInput, + ) { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + inputCount: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + closedSessionKeys.delete(sessionKey); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }); + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index d30b4445ea82..0ba4ecf49b9a 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -74,13 +74,14 @@ export const make = Effect.gen(function* () { return { ...base, accounts: accounts.success }; }); - const publish = (next: ReadonlyArray) => - Effect.gen(function* () { - const changed = yield* Ref.modify(stateRef, (previous) => - Equal.equals(previous, next) ? [false, previous] : [true, next], - ); - if (changed) yield* PubSub.publish(changes, next); - }); + const publish = Effect.fn("UsageLimitSources.publish")(function* ( + next: ReadonlyArray, + ) { + const changed = yield* Ref.modify(stateRef, (previous) => + Equal.equals(previous, next) ? [false, previous] : [true, next], + ); + 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 @@ -102,25 +103,24 @@ export const make = Effect.gen(function* () { }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); // Shares the refresh lock so a stale in-flight read cannot overwrite a redemption. - const consumeResetCredit = (input: UsageLimitSourceConsumeResetCreditInput) => - Effect.gen(function* () { - const settings = yield* settingsService.getSettings.pipe( - Effect.mapError( - () => new UsageLimitSourceError({ detail: "Could not read hub settings." }), - ), - ); - const config = settings.usageLimitSources[input.sourceId]; - if (!config?.enabled || !config.managementKey) { - return yield* new UsageLimitSourceError({ - detail: "The usage limit source is missing or disabled.", - }); - } - const result = yield* api.consume(config, input.accountId, input.creditId); - const snapshot = yield* readSource(input.sourceId, config); - const previous = yield* Ref.get(stateRef); - yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); - return result; - }).pipe(refreshLock.withPermits(1)); + const consumeResetCredit = Effect.fn("UsageLimitSources.consumeResetCredit")(function* ( + input: UsageLimitSourceConsumeResetCreditInput, + ) { + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), + ); + const config = settings.usageLimitSources[input.sourceId]; + if (!config?.enabled || !config.managementKey) { + return yield* new UsageLimitSourceError({ + detail: "The usage limit source is missing or disabled.", + }); + } + const result = yield* api.consume(config, input.accountId, input.creditId); + const snapshot = yield* readSource(input.sourceId, config); + const previous = yield* Ref.get(stateRef); + yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); + return result; + }, refreshLock.withPermits(1)); yield* settingsService.streamChanges.pipe( Stream.map((settings) => settings.usageLimitSources), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 49d8d179664b..c27321b51777 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -451,66 +451,63 @@ export const make = Effect.gen(function* () { * than completion order: saved records of deleted transcripts aggregate in * cache order, where the first copy of a duplicate wins. */ - const readFileRecords = ( + const readFileRecords = Effect.fn("UsageService.readFileRecords")(function* ( filePath: string, size: number, mtimeMs: number, provider: UsageProviderKind, format: TranscriptUsageFormat, - ): Effect.Effect<{ + ): Effect.fn.Return<{ readonly records: readonly UsageRecord[]; readonly failed?: true; readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; - }> => - Effect.gen(function* () { - const cached = fileCache.get(filePath); - // Provider is part of the identity: if both providers were ever pointed - // at one directory, a hit parsed by the other parser must not be reused. - if ( - cached && - cached.size === size && - cached.mtimeMs === mtimeMs && - cached.provider === provider - ) { - return { - records: - cached.tailRecords.length === 0 - ? cached.records - : [...cached.records, ...cached.tailRecords], - }; - } - - // Only a strictly grown file may resume. Same size with a new mtime, or - // a shrunken file, means rewritten content; re-parse it whole. - const resumeFrom = - cached !== undefined && cached.provider === provider && size > cached.size - ? cached.position - : undefined; - - const parsed = yield* Effect.promise(() => - readTranscriptRecords(filePath, format, resumeFrom), - ); - // A read failure is not an empty transcript: caching it under this - // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) - return { - records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], - failed: true, - }; - - const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + }> { + const cached = fileCache.get(filePath); + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return { + records: + cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords], + }; + } + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, format, resumeFrom)); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) return { - records: tailRecords.length === 0 ? records : [...records, ...tailRecords], - update: { - entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, - replaces: cached, - }, + records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + failed: true, }; - }); + + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + + return { + records: tailRecords.length === 0 ? records : [...records, ...tailRecords], + update: { + entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, + replaces: cached, + }, + }; + }); interface ScannedDir { readonly provider: UsageProviderKind; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 46e781a1d5b5..5c7b20b34c55 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -783,16 +783,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ); - const resolveGitCommonDir = (cwd: string) => - Effect.gen(function* () { - const result = yield* execute({ - operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", - cwd, - args: ["rev-parse", "--git-common-dir"], - }); - const gitCommonDir = result.stdout.trim(); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + const resolveGitCommonDir = Effect.fn("GitVcsDriver.resolveGitCommonDir")(function* ( + cwd: string, + ) { + const result = yield* execute({ + operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], }); + const gitCommonDir = result.stdout.trim(); + return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, 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 diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4cefc3ee1daf..f2c32a94a281 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -600,14 +600,16 @@ const nowUnixNano = DateTime.now.pipe( Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n), ); -const addCurrentSpanEvent = (name: string, attributes: Record) => - Effect.gen(function* () { - const span = yield* Effect.currentSpan; - const timestamp = yield* nowUnixNano; - yield* Effect.sync(() => { - span.event(name, timestamp, compactTraceAttributes(attributes)); - }); - }).pipe( +const addCurrentSpanEvent = + /* This helper annotates the existing operation span. */ + Effect.fnUntraced( + function* (name: string, attributes: Record) { + const span = yield* Effect.currentSpan; + const timestamp = yield* nowUnixNano; + yield* Effect.sync(() => { + span.event(name, timestamp, compactTraceAttributes(attributes)); + }); + }, Effect.catchTags({ NoSuchElementError: () => Effect.void, }), @@ -1440,15 +1442,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), }, ); - const invalidateStatusStaticCaches = (cwd: string) => - Effect.gen(function* () { + const invalidateStatusStaticCaches = Effect.fn("GitVcsDriverCore.invalidateStatusStaticCaches")( + function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), ); const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); yield* Cache.invalidate(defaultBranchCache, cacheKey); yield* Cache.invalidate(originExistsCache, cacheKey); - }); + }, + ); const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd); @@ -3994,22 +3997,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* * Unlinks links under `target` without following them and removes the * directories that leaves empty. Returns false when anything else remains. */ - const removeLeftoverLinks = ( + // Recursive filesystem traversal can visit many entries; removeWorktree owns the operation span. + const removeLeftoverLinks = Effect.fnUntraced(function* ( target: string, - ): Effect.Effect => - Effect.gen(function* () { - if (Option.isSome(yield* fileSystem.readLink(target).pipe(Effect.option))) { - yield* fileSystem.remove(target); - return true; - } - if ((yield* fileSystem.stat(target)).type !== "Directory") return false; - let empty = true; - for (const name of yield* fileSystem.readDirectory(target)) { - if (!(yield* removeLeftoverLinks(path.join(target, name)))) empty = false; - } - // Only an empty directory goes, so files written since the listing stay. - return empty && (yield* removeEmptyDirectory(target)); - }); + ): Effect.fn.Return { + if (Option.isSome(yield* fileSystem.readLink(target).pipe(Effect.option))) { + yield* fileSystem.remove(target); + return true; + } + if ((yield* fileSystem.stat(target)).type !== "Directory") return false; + let empty = true; + for (const name of yield* fileSystem.readDirectory(target)) { + if (!(yield* removeLeftoverLinks(path.join(target, name)))) empty = false; + } + // Only an empty directory goes, so files written since the listing stay. + return empty && (yield* removeEmptyDirectory(target)); + }); const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index fec9f74b4668..cfa253aafa31 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -555,88 +555,87 @@ export const make = Effect.gen(function* () { ); }); - const makeRemoteRefreshLoop = ( + // A subscription owns this loop; keep each poll's spans out of a never-ending parent span. + const makeRemoteRefreshLoop = Effect.fnUntraced(function* ( cwd: string, demandCwdsRef: Ref.Ref>, refreshImmediately: boolean, - ) => { - return Effect.gen(function* () { - const consecutiveFailuresRef = yield* Ref.make(0); - const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); - const refreshRemoteStatusIfEnabled = Effect.gen(function* () { - const configuredInterval = yield* automaticFetchInterval; - const activeInterval = Duration.isZero(configuredInterval) - ? DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL - : configuredInterval; - const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); - if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { - return activeInterval; - } + ) { + const consecutiveFailuresRef = yield* Ref.make(0); + const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); + const refreshRemoteStatusIfEnabled = Effect.gen(function* () { + const configuredInterval = yield* automaticFetchInterval; + const activeInterval = Duration.isZero(configuredInterval) + ? DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL + : configuredInterval; + const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); + if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { + return activeInterval; + } - const demandCwds = yield* Ref.get(demandCwdsRef); - const shouldRun = - needsInitialRefresh || - (yield* Effect.forEach( - [...demandCwds.keys()], - (demandCwd) => - backgroundPolicy.shouldRunScopeWork({ - type: "vcs-status", - cwd: demandCwd, - }), - { concurrency: "unbounded" }, - )).some(Boolean); - if (!shouldRun) { - return activeInterval; - } + const demandCwds = yield* Ref.get(demandCwdsRef); + const shouldRun = + needsInitialRefresh || + (yield* Effect.forEach( + [...demandCwds.keys()], + (demandCwd) => + backgroundPolicy.shouldRunScopeWork({ + type: "vcs-status", + cwd: demandCwd, + }), + { concurrency: "unbounded" }, + )).some(Boolean); + if (!shouldRun) { + return activeInterval; + } - const exit = yield* refreshRemoteStatus(cwd, { - refreshUpstream: !Duration.isZero(configuredInterval), - policyCwds: [...demandCwds.keys()], - }).pipe(Effect.exit); - if (Exit.isSuccess(exit)) { - yield* Ref.set(needsInitialRefreshRef, false); - yield* Ref.set(consecutiveFailuresRef, 0); - return activeInterval; - } + const exit = yield* refreshRemoteStatus(cwd, { + refreshUpstream: !Duration.isZero(configuredInterval), + policyCwds: [...demandCwds.keys()], + }).pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + yield* Ref.set(needsInitialRefreshRef, false); + yield* Ref.set(consecutiveFailuresRef, 0); + return activeInterval; + } - const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); - if (interruptionReasons.length > 0) { - return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); - } + const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); + if (interruptionReasons.length > 0) { + return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); + } - const consecutiveFailures = yield* Ref.updateAndGet( - consecutiveFailuresRef, - (count) => count + 1, - ); - const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); - yield* Effect.logWarning("VCS remote status refresh failed", { - cwdLength: cwd.length, - ...remoteRefreshFailureDiagnostics(exit.cause), - consecutiveFailures, - nextDelayMs: Duration.toMillis(nextDelay), - }); - return nextDelay; + const consecutiveFailures = yield* Ref.updateAndGet( + consecutiveFailuresRef, + (count) => count + 1, + ); + const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); + yield* Effect.logWarning("VCS remote status refresh failed", { + cwdLength: cwd.length, + ...remoteRefreshFailureDiagnostics(exit.cause), + consecutiveFailures, + nextDelayMs: Duration.toMillis(nextDelay), }); + return nextDelay; + }); - if (!refreshImmediately) { - const configuredInterval = yield* automaticFetchInterval; - yield* Effect.sleep( - Duration.isZero(configuredInterval) - ? DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL - : configuredInterval, - ); - } + if (!refreshImmediately) { + const configuredInterval = yield* automaticFetchInterval; + yield* Effect.sleep( + Duration.isZero(configuredInterval) + ? DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL + : configuredInterval, + ); + } - return yield* refreshRemoteStatusIfEnabled.pipe( - Effect.repeat( - Schedule.identity().pipe( - Schedule.addDelay(({ output: delay }) => Effect.succeed(delay)), - ), + return yield* refreshRemoteStatusIfEnabled.pipe( + Effect.repeat( + Schedule.identity().pipe( + Schedule.addDelay(({ output: delay }) => Effect.succeed(delay)), ), - Effect.asVoid, - ); - }); - }; + ), + Effect.asVoid, + ); + }); const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( cwd: string, diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 607db665de71..e40a1a25c123 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -160,20 +160,21 @@ export const make = Effect.gen(function* () { if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { continue; } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { + const recoverRefreshFailure = Effect.fn("WorkspaceEntries.recoverRefreshFailure")( + function* ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) { yield* Effect.logWarning("Failed to refresh workspace search index", { cwd, variant, cause, }); yield* workspaceSearchIndexes.invalidate(indexKey); - }); + }, + ); yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; yield* searchIndex.refresh(); From 889bb95f7ed196cc1417b926508fcd45ba229a87 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:31:01 -0700 Subject: [PATCH 009/458] refactor(server): use Effect functions at transport and startup boundaries (#21999) --- apps/server/src/auth/McpOAuth.ts | 229 +- apps/server/src/auth/SessionStore.ts | 53 +- apps/server/src/auth/dpop.ts | 127 +- apps/server/src/auth/http.ts | 22 +- apps/server/src/auth/mcpOAuthHttp.ts | 11 +- apps/server/src/bootstrap.ts | 67 +- apps/server/src/cli/auth.ts | 33 +- apps/server/src/cli/config.ts | 443 ++- apps/server/src/cli/invocation.ts | 53 +- apps/server/src/cli/pair.ts | 12 +- apps/server/src/cli/project.ts | 34 +- apps/server/src/cli/server.ts | 73 +- .../src/cloud/ManagedEndpointRuntime.ts | 90 +- apps/server/src/cloud/bootService.ts | 105 +- apps/server/src/cloud/config.ts | 10 +- apps/server/src/cloud/selfUpdate.ts | 55 +- apps/server/src/http.ts | 9 +- apps/server/src/mcp/AcpMcpStdioBridge.ts | 200 +- apps/server/src/mcp/McpHttpServer.ts | 251 +- apps/server/src/mcp/OrchestratorMcpService.ts | 2380 ++++++++--------- .../src/mcp/toolkits/device/handlers.ts | 65 +- .../src/preview/DesktopBrowserChannel.ts | 113 +- apps/server/src/preview/ServerBrowser.ts | 407 ++- .../server/src/preview/ServerBrowserStream.ts | 350 +-- apps/server/src/serverRuntimeStartup.ts | 603 +++-- apps/server/src/ws.ts | 271 +- 26 files changed, 3048 insertions(+), 3018 deletions(-) diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 7ce72153b493..b7e63cc80642 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -288,8 +288,8 @@ const make = Effect.gen(function* () { ]), ); - const register: McpOAuth["Service"]["register"] = (metadata) => - Effect.gen(function* () { + const register: McpOAuth["Service"]["register"] = Effect.fn("McpOAuth.register")( + function* (metadata) { const rawName = metadata.client_name?.trim() ?? ""; const name = (rawName.length > 0 ? rawName : DEFAULT_CLIENT_NAME).slice( 0, @@ -314,59 +314,58 @@ const make = Effect.gen(function* () { // asks for refresh_token, and hosted agents may ask for a client secret; every client // is registered as public and proves itself with PKCE instead. return { clientId: signClientId(name, redirectUris), name, redirectUris }; - }); + }, + ); - const validateAuthorization: McpOAuth["Service"]["validateAuthorization"] = ({ urls, request }) => - Effect.gen(function* () { - const client = parseClientId(request.client_id ?? ""); - if (client === undefined) { - return yield* new McpOAuthPageError({ - description: "This sign-in link names an unknown app. Start the sign-in again from it.", - }); - } - const redirectUri = request.redirect_uri; - if ( - redirectUri === undefined || - !client.redirectUris.some((registered) => redirectMatches(registered, redirectUri)) - ) { - return yield* new McpOAuthPageError({ - description: "This sign-in link sends you to an address the app did not register.", - }); - } - const state = request.state; - const fail = (error: McpOAuthRedirectError["error"], description: string) => - new McpOAuthRedirectError({ - error, - description, - redirectUri, - ...(state === undefined ? {} : { state }), - }); - if (request.response_type !== "code") { - return yield* fail("unsupported_response_type", "Only response_type=code is supported."); - } - const codeChallenge = request.code_challenge; - if ( - request.code_challenge_method !== "S256" || - codeChallenge === undefined || - !CODE_CHALLENGE_PATTERN.test(codeChallenge) - ) { - return yield* fail("invalid_request", "PKCE with code_challenge_method=S256 is required."); - } - if (!sameResource(urls, request.resource)) { - return yield* fail( - "invalid_target", - `This server only issues tokens for ${urls.resource}.`, - ); - } - return { - client, + const validateAuthorization: McpOAuth["Service"]["validateAuthorization"] = Effect.fn( + "McpOAuth.validateAuthorization", + )(function* ({ urls, request }) { + const client = parseClientId(request.client_id ?? ""); + if (client === undefined) { + return yield* new McpOAuthPageError({ + description: "This sign-in link names an unknown app. Start the sign-in again from it.", + }); + } + const redirectUri = request.redirect_uri; + if ( + redirectUri === undefined || + !client.redirectUris.some((registered) => redirectMatches(registered, redirectUri)) + ) { + return yield* new McpOAuthPageError({ + description: "This sign-in link sends you to an address the app did not register.", + }); + } + const state = request.state; + const fail = (error: McpOAuthRedirectError["error"], description: string) => + new McpOAuthRedirectError({ + error, + description, redirectUri, - codeChallenge, - state, - resource: urls.resource, - issuer: urls.issuer, - } satisfies AuthorizationRequest; - }); + ...(state === undefined ? {} : { state }), + }); + if (request.response_type !== "code") { + return yield* fail("unsupported_response_type", "Only response_type=code is supported."); + } + const codeChallenge = request.code_challenge; + if ( + request.code_challenge_method !== "S256" || + codeChallenge === undefined || + !CODE_CHALLENGE_PATTERN.test(codeChallenge) + ) { + return yield* fail("invalid_request", "PKCE with code_challenge_method=S256 is required."); + } + if (!sameResource(urls, request.resource)) { + return yield* fail("invalid_target", `This server only issues tokens for ${urls.resource}.`); + } + return { + client, + redirectUri, + codeChallenge, + state, + resource: urls.resource, + issuer: urls.issuer, + } satisfies AuthorizationRequest; + }); const approvingBrowserSession: McpOAuth["Service"]["approvingBrowserSession"] = ( request, @@ -383,73 +382,72 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => undefined), ); - const mintCode = (authorization: AuthorizationRequest, access: AuthMcpClientAccess) => - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const code = Buffer.from(yield* crypto.randomBytes(32).pipe(Effect.orDie)).toString( - "base64url", - ); - yield* Ref.update(codes, (current) => { - const next = new Map( - Array.from(current).filter(([, pending]) => pending.expiresAtMs > now), - ); - next.set(code, { - clientId: authorization.client.clientId, - clientName: authorization.client.name, - redirectUri: authorization.redirectUri, - codeChallenge: authorization.codeChallenge, - resource: authorization.resource, - access, - expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, - }); - return next; + const mintCode = Effect.fn("McpOAuth.mintCode")(function* ( + authorization: AuthorizationRequest, + access: AuthMcpClientAccess, + ) { + const now = yield* Clock.currentTimeMillis; + const code = Buffer.from(yield* crypto.randomBytes(32).pipe(Effect.orDie)).toString( + "base64url", + ); + yield* Ref.update(codes, (current) => { + const next = new Map(Array.from(current).filter(([, pending]) => pending.expiresAtMs > now)); + next.set(code, { + clientId: authorization.client.clientId, + clientName: authorization.client.name, + redirectUri: authorization.redirectUri, + codeChallenge: authorization.codeChallenge, + resource: authorization.resource, + access, + expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, }); - return code; + return next; }); + return code; + }); - const approve: McpOAuth["Service"]["approve"] = (input) => - Effect.gen(function* () { - const { decision } = input; - if (decision._tag === "pairing-code") { - yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => - Effect.logError("MCP approval code check failed.", { cause }).pipe( - Effect.andThen( - Effect.fail( - new McpOAuthPageError({ - description: "The code could not be checked. Try again.", - }), - ), + const approve: McpOAuth["Service"]["approve"] = Effect.fn("McpOAuth.approve")(function* (input) { + const { decision } = input; + if (decision._tag === "pairing-code") { + yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => + Effect.logError("MCP approval code check failed.", { cause }).pipe( + Effect.andThen( + Effect.fail( + new McpOAuthPageError({ + description: "The code could not be checked. Try again.", + }), ), ), ), - ); - } else { - const session = yield* approvingBrowserSession(input.request, input.authorization); - if ( - session === undefined || - !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) || - !EnvironmentAuth.mcpClientScopes(decision.access).every((scope) => - session.scopes.includes(scope), - ) - ) { - return yield* new McpOAuthPageError({ - description: "Your session cannot approve this request. Enter a pairing code instead.", - }); - } + ), + ); + } else { + const session = yield* approvingBrowserSession(input.request, input.authorization); + if ( + session === undefined || + !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) || + !EnvironmentAuth.mcpClientScopes(decision.access).every((scope) => + session.scopes.includes(scope), + ) + ) { + return yield* new McpOAuthPageError({ + description: "Your session cannot approve this request. Enter a pairing code instead.", + }); } - const code = yield* mintCode(input.authorization, decision.access); - yield* Effect.logInfo("Approved an MCP client sign-in.", { - client: input.authorization.client.name, - access: decision.access, - method: decision._tag, - }); - return redirectWith(input.authorization.redirectUri, { - code, - state: input.authorization.state, - iss: input.authorization.issuer, - }); + } + const code = yield* mintCode(input.authorization, decision.access); + yield* Effect.logInfo("Approved an MCP client sign-in.", { + client: input.authorization.client.name, + access: decision.access, + method: decision._tag, + }); + return redirectWith(input.authorization.redirectUri, { + code, + state: input.authorization.state, + iss: input.authorization.issuer, }); + }); const deny: McpOAuth["Service"]["deny"] = (authorization) => redirectWith(authorization.redirectUri, { @@ -458,8 +456,8 @@ const make = Effect.gen(function* () { iss: authorization.issuer, }); - const exchangeCode: McpOAuth["Service"]["exchangeCode"] = ({ request, urls, token }) => - Effect.gen(function* () { + const exchangeCode: McpOAuth["Service"]["exchangeCode"] = Effect.fn("McpOAuth.exchangeCode")( + function* ({ request, urls, token }) { const fail = (error: AuthMcpTokenError["error"], description: string) => new AuthMcpTokenError({ error, error_description: description }); if (token.grant_type !== "authorization_code") { @@ -517,7 +515,8 @@ const make = Effect.gen(function* () { expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)), scope: encodeOAuthScope(EnvironmentAuth.mcpClientScopes(pending.access)), }; - }); + }, + ); return McpOAuth.of({ register, diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index b9fda7a67e40..5c08e978f912 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -539,33 +539,34 @@ export const make = Effect.gen(function* () { sessionId, }).pipe(Effect.asVoid); - const loadActiveSession = (sessionId: AuthSessionId) => - Effect.gen(function* () { - const row = yield* authSessions.getById({ sessionId }); - if (Option.isNone(row) || row.value.revokedAt !== null) { - return Option.none(); - } + const loadActiveSession = Effect.fn("SessionStore.loadActiveSession")(function* ( + sessionId: AuthSessionId, + ) { + const row = yield* authSessions.getById({ sessionId }); + if (Option.isNone(row) || row.value.revokedAt !== null) { + return Option.none(); + } - const connectedSessions = yield* Ref.get(connectedSessionsRef); - const connected = connectedSessions.has(row.value.sessionId); - const now = yield* DateTime.now; - if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) { - return Option.none(); - } - return Option.some( - toAuthClientSession({ - sessionId: row.value.sessionId, - subject: row.value.subject, - scopes: row.value.scopes, - method: row.value.method, - client: toClientMetadata(row.value.client), - issuedAt: row.value.issuedAt, - expiresAt: row.value.expiresAt, - lastConnectedAt: row.value.lastConnectedAt, - connected, - }), - ); - }); + const connectedSessions = yield* Ref.get(connectedSessionsRef); + const connected = connectedSessions.has(row.value.sessionId); + const now = yield* DateTime.now; + if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) { + return Option.none(); + } + return Option.some( + toAuthClientSession({ + sessionId: row.value.sessionId, + subject: row.value.subject, + scopes: row.value.scopes, + method: row.value.method, + client: toClientMetadata(row.value.client), + issuedAt: row.value.issuedAt, + expiresAt: row.value.expiresAt, + lastConnectedAt: row.value.lastConnectedAt, + connected, + }), + ); + }); const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) => Ref.modify(connectedSessionsRef, (current) => { diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index f191c354e14f..9d2262e6fc57 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -52,74 +52,73 @@ export const mapDpopReplayStoreError = ( cause: error, }); -export const verifyRequestDpopProof = (input: { +export const verifyRequestDpopProof = Effect.fn("dpop.verifyRequestDpopProof")(function* (input: { readonly request: HttpServerRequest.HttpServerRequest; readonly expectedThumbprint?: string; readonly expectedAccessToken?: string; -}) => - Effect.gen(function* () { - const proof = input.request.headers.dpop; - const url = HttpServerRequest.toURL(input.request); - if (Option.isNone(url)) { - return yield* new ServerAuthInvalidCredentialError({ - diagnostic: "Invalid DPoP request URL.", - }); - } - const now = yield* DateTime.now; - const result = verifyDpopProof({ - proof, - method: input.request.method, - url: url.value.href, - nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), - ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), - ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), +}) { + const proof = input.request.headers.dpop; + const url = HttpServerRequest.toURL(input.request); + if (Option.isNone(url)) { + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: "Invalid DPoP request URL.", }); - if (!result.ok) { - yield* Effect.annotateCurrentSpan({ - "environment.dpop.failure_code": result.code, - }); - return yield* new ServerAuthInvalidCredentialError({ - diagnostic: result.reason, - dpopFailureReason: mapDpopFailureReason(result.code), - }); - } - const secretStore = yield* ServerSecretStore.ServerSecretStore; - const replayKey = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => - crypto.digest("SHA-256", new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)), + } + const now = yield* DateTime.now; + const result = verifyDpopProof({ + proof, + method: input.request.method, + url: url.value.href, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), + ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), + }); + if (!result.ok) { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": result.code, + }); + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: result.reason, + dpopFailureReason: mapDpopFailureReason(result.code), + }); + } + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const replayKey = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => + crypto.digest("SHA-256", new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)), + ), + Effect.map(Base64Url.encode), + Effect.mapError( + (cause) => + new ServerAuthDpopReplayKeyCalculationError({ + cause, + }), + ), + ); + yield* secretStore + .create( + `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`, + new TextEncoder().encode( + [ + `thumbprint=${result.thumbprint}`, + `jti=${result.jti}`, + `iat=${result.iat}`, + `consumedAt=${DateTime.formatIso(now)}`, + ].join("\n"), ), - Effect.map(Base64Url.encode), - Effect.mapError( - (cause) => - new ServerAuthDpopReplayKeyCalculationError({ - cause, - }), + ) + .pipe( + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => + Effect.gen(function* () { + const mapped = mapDpopReplayStoreError(error); + if (mapped._tag === "ServerAuthInvalidCredentialError") { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": mapped.dpopFailureReason, + }); + } + return yield* mapped; + }), ), ); - yield* secretStore - .create( - `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`, - new TextEncoder().encode( - [ - `thumbprint=${result.thumbprint}`, - `jti=${result.jti}`, - `iat=${result.iat}`, - `consumedAt=${DateTime.formatIso(now)}`, - ].join("\n"), - ), - ) - .pipe( - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - Effect.gen(function* () { - const mapped = mapDpopReplayStoreError(error); - if (mapped._tag === "ServerAuthInvalidCredentialError") { - yield* Effect.annotateCurrentSpan({ - "environment.dpop.failure_code": mapped.dpopFailureReason, - }); - } - return yield* mapped; - }), - ), - ); - return result.thumbprint; - }); + return result.thumbprint; +}); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 0e02c8782cb3..ae052abbf01e 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -51,8 +51,9 @@ const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")), ); -const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) => - Effect.gen(function* () { +const appendDpopChallengeOnUnauthorized = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (error: EnvironmentAuthInvalidError) { const request = yield* HttpServerRequest.HttpServerRequest; const usesDpop = (request.originalUrl.startsWith("/oauth/token") && request.headers.dpop !== undefined) || @@ -68,8 +69,9 @@ const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( Effect.orElseSucceed(() => "unavailable"), ); -export function annotateEnvironmentRequest(endpoint: string) { - return Effect.gen(function* () { +export const annotateEnvironmentRequest = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (endpoint: string) { const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); const traceId = yield* currentEnvironmentTraceId; @@ -91,7 +93,6 @@ export function annotateEnvironmentRequest(endpoint: string) { "url.path": url._tag === "Some" ? url.value.pathname : "unknown", }); }); -} export function failEnvironmentAuthInvalid( reason: EnvironmentAuthInvalidReason, @@ -160,8 +161,9 @@ export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReaso * authenticate the way the `/ws` upgrade does: a cookie for browser sessions, or a short-lived * `wsTicket` minted over authenticated HTTP for bearer and DPoP clients. */ -export const authenticateMediaRequest = (requiredScope: AuthEnvironmentScope) => - Effect.gen(function* () { +export const authenticateMediaRequest = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (requiredScope: AuthEnvironmentScope) { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( @@ -183,8 +185,9 @@ export const authenticateMediaRequest = (requiredScope: AuthEnvironmentScope) => return session; }); -export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) { - return Effect.gen(function* () { +export const failEnvironmentInternal = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (reason: EnvironmentInternalErrorReason, error?: unknown) { const traceId = yield* currentEnvironmentTraceId; if (error !== undefined) { yield* Effect.logError("environment api operation failed", { @@ -195,7 +198,6 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, } return yield* new EnvironmentInternalError({ code: "internal_error", reason, traceId }); }); -} const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => Effect.fromResult( diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts index 81a343dd8c14..d8f2471872e2 100644 --- a/apps/server/src/auth/mcpOAuthHttp.ts +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -38,11 +38,12 @@ const requestUrls = Effect.map(HttpServerRequest.HttpServerRequest, McpOAuth.req * An unverified client or redirect is reported, never redirected; any other problem goes back to * the agent through its (verified) redirect URI. */ -const forwardedAuthorization = ( - oauth: McpOAuth.McpOAuth["Service"], - request: AuthMcpAuthorizationRequest, -) => - Effect.gen(function* () { +const forwardedAuthorization = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* ( + oauth: McpOAuth.McpOAuth["Service"], + request: AuthMcpAuthorizationRequest, + ) { const urls = yield* requestUrls; return yield* oauth.validateAuthorization({ urls, request }).pipe( Effect.catchTags({ diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 708a8fd9119b..09c8277a142d 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -166,43 +166,44 @@ const isFdReady = (fd: number) => }), ); -const makeBootstrapInputStream = (fd: number) => - Effect.gen(function* () { - const platform = yield* HostProcess.Platform; - const fdPath = resolveFdPath(fd, platform); - return yield* Effect.try({ - try: () => { - if (fdPath === undefined) { - return makeDirectBootstrapStream(fd); - } +const makeBootstrapInputStream = Effect.fn("bootstrap.makeBootstrapInputStream")(function* ( + fd: number, +) { + const platform = yield* HostProcess.Platform; + const fdPath = resolveFdPath(fd, platform); + return yield* Effect.try({ + try: () => { + if (fdPath === undefined) { + return makeDirectBootstrapStream(fd); + } - let streamFd: number | undefined; - try { - streamFd = NodeFS.openSync(fdPath, "r"); - return NodeFS.createReadStream("", { - fd: streamFd, - encoding: "utf8", - autoClose: true, - }); - } catch (error) { - if (isBootstrapFdPathDuplicationError(error)) { - if (streamFd !== undefined) { - NodeFS.closeSync(streamFd); - } - return makeDirectBootstrapStream(fd); + let streamFd: number | undefined; + try { + streamFd = NodeFS.openSync(fdPath, "r"); + return NodeFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + } catch (error) { + if (isBootstrapFdPathDuplicationError(error)) { + if (streamFd !== undefined) { + NodeFS.closeSync(streamFd); } - throw error; + return makeDirectBootstrapStream(fd); } - }, - catch: (error) => - new BootstrapInputStreamOpenError({ - fd, - platform, - ...(fdPath === undefined ? {} : { fdPath }), - cause: error, - }), - }); + throw error; + } + }, + catch: (error) => + new BootstrapInputStreamOpenError({ + fd, + platform, + ...(fdPath === undefined ? {} : { fdPath }), + cause: error, + }), }); +}); const makeDirectBootstrapStream = (fd: number): NodeStream.Readable => { try { diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 8195538a55ce..0aeb7d6a3b5a 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -23,29 +23,28 @@ import { resolveCliAuthConfig, } from "./config.ts"; -const runWithEnvironmentAuth = ( +const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* ( flags: CliAuthLocationFlags, run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, options?: { readonly quietLogs?: boolean; }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; - return yield* Effect.gen(function* () { - const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; - return yield* run(environmentAuth); - }).pipe( - Effect.provide( - Layer.mergeAll(EnvironmentAuth.layerRuntime).pipe( - Layer.provide(ServerConfig.layer(config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ), +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + return yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* run(environmentAuth); + }).pipe( + Effect.provide( + Layer.mergeAll(EnvironmentAuth.layerRuntime).pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), - ); - }); + ), + ); +}); const ttlFlag = Flag.String("ttl").pipe( Flag.withSchema(DurationFromString), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 0ee42f76dec5..c7271b795b55 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -249,7 +249,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st return parsePersistedServerObservabilitySettings(raw); }); -export const resolveServerConfig = ( +export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(function* ( flags: CliServerFlags, cliLogLevel: Option.Option, options?: { @@ -257,235 +257,234 @@ export const resolveServerConfig = ( readonly forceAutoBootstrapProjectFromCwd?: boolean; readonly rejectRunningServer?: boolean; }, -) => - Effect.gen(function* () { - const { findAvailablePort } = yield* NetService.NetService; - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const env = yield* EnvServerConfig; - const normalizedFlags = { - mode: flags.mode ?? Option.none(), - port: flags.port ?? Option.none(), - host: flags.host ?? Option.none(), - baseDir: flags.baseDir ?? Option.none(), - cwd: flags.cwd ?? Option.none(), - devUrl: flags.devUrl ?? Option.none(), - noBrowser: flags.noBrowser ?? Option.none(), - bootstrapFd: flags.bootstrapFd ?? Option.none(), - autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), - logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), - tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), - tailscaleServePort: flags.tailscaleServePort ?? Option.none(), - } satisfies CliServerFlags; - const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; - const bootstrapEnvelope = - bootstrapFd !== undefined - ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd) - : Option.none(); - const bootstrap = Option.getOrUndefined(bootstrapEnvelope); +) { + const { findAvailablePort } = yield* NetService.NetService; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const env = yield* EnvServerConfig; + const normalizedFlags = { + mode: flags.mode ?? Option.none(), + port: flags.port ?? Option.none(), + host: flags.host ?? Option.none(), + baseDir: flags.baseDir ?? Option.none(), + cwd: flags.cwd ?? Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: flags.noBrowser ?? Option.none(), + bootstrapFd: flags.bootstrapFd ?? Option.none(), + autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), + logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), + tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), + tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + } satisfies CliServerFlags; + const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd) + : Option.none(); + const bootstrap = Option.getOrUndefined(bootstrapEnvelope); - const mode: ServerConfig.RuntimeMode = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.mode, - Option.fromUndefinedOr(env.mode), - Option.fromUndefinedOr(bootstrap?.mode), - ), - () => "web", - ); + const mode: ServerConfig.RuntimeMode = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.mode, + Option.fromUndefinedOr(env.mode), + Option.fromUndefinedOr(bootstrap?.mode), + ), + () => "web", + ); - const port = yield* Option.match( - resolveOptionPrecedence( - normalizedFlags.port, - Option.fromUndefinedOr(env.port), - Option.fromUndefinedOr(bootstrap?.port), - ), - { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (mode === "desktop") { - return Effect.succeed(ServerConfig.DEFAULT_PORT); - } - return findAvailablePort(ServerConfig.DEFAULT_PORT); - }, + const port = yield* Option.match( + resolveOptionPrecedence( + normalizedFlags.port, + Option.fromUndefinedOr(env.port), + Option.fromUndefinedOr(bootstrap?.port), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (mode === "desktop") { + return Effect.succeed(ServerConfig.DEFAULT_PORT); + } + return findAvailablePort(ServerConfig.DEFAULT_PORT); }, - ); - const devUrl = Option.getOrElse( - resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), - () => undefined, - ); - const devAuthToken = - mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined; - const explicitBaseDir = resolveOptionPrecedence( - normalizedFlags.baseDir, - Option.fromUndefinedOr(env.t3Home), - ).pipe(Option.filter((value) => value.trim().length > 0)); - const baseDir = yield* resolveBaseDir( - Option.getOrUndefined( - resolveOptionPrecedence(explicitBaseDir, Option.fromUndefinedOr(bootstrap?.t3Home)), - ), - ); - const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); - const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); - const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { - baseDirIsExplicit: Option.isSome(explicitBaseDir), - }); - // An interactive CLI must not start over a discovered server. Lifetime locking - // and supervisor handoff are separate; this preflight cannot arbitrate two starts. - if (options?.rejectRunningServer && mode === "web") { - const runtime = yield* readPersistedServerRuntimeState(derivedPaths.serverRuntimeStatePath); - // A killed container can leave a record with the new server's own PID. - if ( - Option.isSome(runtime) && - runtime.value.pid > 0 && - runtime.value.pid !== process.pid && - isProcessAlive(runtime.value.pid) - ) { - return yield* new CliError.UserError({ - cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). Connect to that server, stop it before starting another, or use a different --base-dir.`, - }); - } + }, + ); + const devUrl = Option.getOrElse( + resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), + () => undefined, + ); + const devAuthToken = + mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined; + const explicitBaseDir = resolveOptionPrecedence( + normalizedFlags.baseDir, + Option.fromUndefinedOr(env.t3Home), + ).pipe(Option.filter((value) => value.trim().length > 0)); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined( + resolveOptionPrecedence(explicitBaseDir, Option.fromUndefinedOr(bootstrap?.t3Home)), + ), + ); + const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); + const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { + baseDirIsExplicit: Option.isSome(explicitBaseDir), + }); + // An interactive CLI must not start over a discovered server. Lifetime locking + // and supervisor handoff are separate; this preflight cannot arbitrate two starts. + if (options?.rejectRunningServer && mode === "web") { + const runtime = yield* readPersistedServerRuntimeState(derivedPaths.serverRuntimeStatePath); + // A killed container can leave a record with the new server's own PID. + if ( + Option.isSome(runtime) && + runtime.value.pid > 0 && + runtime.value.pid !== process.pid && + isProcessAlive(runtime.value.pid) + ) { + return yield* new CliError.UserError({ + cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). Connect to that server, stop it before starting another, or use a different --base-dir.`, + }); } - yield* fs.makeDirectory(cwd, { recursive: true }); - yield* ServerConfig.ensureServerDirectories(derivedPaths); - const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( - derivedPaths.settingsPath, - ); - const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; - yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); - const startupPresentation = options?.startupPresentation ?? "browser"; - const isHeadlessStartup = startupPresentation === "headless"; - const noBrowser = Option.getOrElse( - resolveOptionPrecedence( - isHeadlessStartup ? Option.some(true) : Option.none(), - normalizedFlags.noBrowser, - Option.fromUndefinedOr(env.noBrowser), - Option.fromUndefinedOr(bootstrap?.noBrowser), - ), - () => mode === "desktop", - ); - const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; - const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret; - const shellEnvironmentPrepared = bootstrap?.shellEnvironmentPrepared; - const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; - const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; - const desktopBrowserFd = bootstrap?.desktopBrowserFd; - const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; - const resourceMonitorPath = bootstrap?.resourceMonitorPath; - const autoBootstrapProjectFromCwd = Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), - isHeadlessStartup ? Option.some(false) : Option.none(), - normalizedFlags.autoBootstrapProjectFromCwd, - Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), - ), - () => mode === "web", - ); - const logWebSocketEvents = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.logWebSocketEvents, - Option.fromUndefinedOr(env.logWebSocketEvents), - ), - () => Boolean(devUrl), - ); - const tailscaleServeEnabled = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.tailscaleServeEnabled, - Option.fromUndefinedOr(env.tailscaleServeEnabled), - Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), - ), - () => false, - ); - const tailscaleServePort = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.tailscaleServePort, - Option.fromUndefinedOr(env.tailscaleServePort), - Option.fromUndefinedOr(bootstrap?.tailscaleServePort), - ), - () => 443, - ); - const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); - const host = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.host, - Option.fromUndefinedOr(env.host), - Option.fromUndefinedOr(bootstrap?.host), - ), - () => (mode === "desktop" ? "127.0.0.1" : undefined), - ); - const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); + } + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* ServerConfig.ensureServerDirectories(derivedPaths); + const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( + derivedPaths.settingsPath, + ); + const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; + yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); + const startupPresentation = options?.startupPresentation ?? "browser"; + const isHeadlessStartup = startupPresentation === "headless"; + const noBrowser = Option.getOrElse( + resolveOptionPrecedence( + isHeadlessStartup ? Option.some(true) : Option.none(), + normalizedFlags.noBrowser, + Option.fromUndefinedOr(env.noBrowser), + Option.fromUndefinedOr(bootstrap?.noBrowser), + ), + () => mode === "desktop", + ); + const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret; + const shellEnvironmentPrepared = bootstrap?.shellEnvironmentPrepared; + const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; + const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; + const desktopBrowserFd = bootstrap?.desktopBrowserFd; + const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; + const resourceMonitorPath = bootstrap?.resourceMonitorPath; + const autoBootstrapProjectFromCwd = Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), + isHeadlessStartup ? Option.some(false) : Option.none(), + normalizedFlags.autoBootstrapProjectFromCwd, + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + ), + () => mode === "web", + ); + const logWebSocketEvents = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.logWebSocketEvents, + Option.fromUndefinedOr(env.logWebSocketEvents), + ), + () => Boolean(devUrl), + ); + const tailscaleServeEnabled = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServeEnabled, + Option.fromUndefinedOr(env.tailscaleServeEnabled), + Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), + ), + () => false, + ); + const tailscaleServePort = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServePort, + Option.fromUndefinedOr(env.tailscaleServePort), + Option.fromUndefinedOr(bootstrap?.tailscaleServePort), + ), + () => 443, + ); + const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); + const host = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.host, + Option.fromUndefinedOr(env.host), + Option.fromUndefinedOr(bootstrap?.host), + ), + () => (mode === "desktop" ? "127.0.0.1" : undefined), + ); + const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - const otel = yield* OtelEnvironment.load; + const otel = yield* OtelEnvironment.load; - const signalExport: SignalExport = { - protocol: env.otlpProtocol, - headers: env.otlpHeaders, - exportIntervalMs: env.otlpExportIntervalMs, - }; - const traces = OtelEnvironment.resolveSignalEndpoint( - otel, - "traces", - { url: env.otlpTracesUrl, export: signalExport }, - bootstrap?.otlpTracesUrl, - persistedObservabilitySettings.otlpTracesUrl, - ); - const metrics = OtelEnvironment.resolveSignalEndpoint( - otel, - "metrics", - { url: env.otlpMetricsUrl, export: signalExport }, - bootstrap?.otlpMetricsUrl, - persistedObservabilitySettings.otlpMetricsUrl, - ); - const logs = OtelEnvironment.resolveSignalEndpoint( - otel, - "logs", - { url: env.otlpLogsUrl, export: signalExport }, - bootstrap?.otlpLogsUrl, - persistedObservabilitySettings.otlpLogsUrl, - ); + const signalExport: SignalExport = { + protocol: env.otlpProtocol, + headers: env.otlpHeaders, + exportIntervalMs: env.otlpExportIntervalMs, + }; + const traces = OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: env.otlpTracesUrl, export: signalExport }, + bootstrap?.otlpTracesUrl, + persistedObservabilitySettings.otlpTracesUrl, + ); + const metrics = OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: env.otlpMetricsUrl, export: signalExport }, + bootstrap?.otlpMetricsUrl, + persistedObservabilitySettings.otlpMetricsUrl, + ); + const logs = OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: env.otlpLogsUrl, export: signalExport }, + bootstrap?.otlpLogsUrl, + persistedObservabilitySettings.otlpLogsUrl, + ); - const config: ServerConfig.ServerConfig["Service"] = { - logLevel, - traceMinLevel: env.traceMinLevel, - traceTimingEnabled: env.traceTimingEnabled, - traceBatchWindowMs: env.traceBatchWindowMs, - traceMaxBytes: env.traceMaxBytes, - traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: traces?.url, - otlpMetricsUrl: metrics?.url, - otlpLogsUrl: logs?.url, - otlpTracesExport: traces?.export ?? signalExport, - otlpMetricsExport: metrics?.export ?? signalExport, - otlpLogsExport: logs?.export ?? signalExport, - otelEnvironment: otel, - mode, - port, - cwd, - baseDir, - ...derivedPaths, - serverTracePath, - host, - staticDir, - devUrl, - ...(devAuthToken === undefined ? {} : { devAuthToken }), - devAllowedOrigins: env.devAllowedOrigins, - noBrowser, - startupPresentation, - desktopBootstrapToken, - ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), - ...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }), - desktopTelemetryFd, - desktopTelemetryControlFd, - desktopBrowserFd, - desktopBrowserControlFd, - resourceMonitorPath, - autoBootstrapProjectFromCwd, - logWebSocketEvents, - tailscaleServeEnabled, - tailscaleServePort, - }; + const config: ServerConfig.ServerConfig["Service"] = { + logLevel, + traceMinLevel: env.traceMinLevel, + traceTimingEnabled: env.traceTimingEnabled, + traceBatchWindowMs: env.traceBatchWindowMs, + traceMaxBytes: env.traceMaxBytes, + traceMaxFiles: env.traceMaxFiles, + otlpTracesUrl: traces?.url, + otlpMetricsUrl: metrics?.url, + otlpLogsUrl: logs?.url, + otlpTracesExport: traces?.export ?? signalExport, + otlpMetricsExport: metrics?.export ?? signalExport, + otlpLogsExport: logs?.export ?? signalExport, + otelEnvironment: otel, + mode, + port, + cwd, + baseDir, + ...derivedPaths, + serverTracePath, + host, + staticDir, + devUrl, + ...(devAuthToken === undefined ? {} : { devAuthToken }), + devAllowedOrigins: env.devAllowedOrigins, + noBrowser, + startupPresentation, + desktopBootstrapToken, + ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), + ...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }), + desktopTelemetryFd, + desktopTelemetryControlFd, + desktopBrowserFd, + desktopBrowserControlFd, + resourceMonitorPath, + autoBootstrapProjectFromCwd, + logWebSocketEvents, + tailscaleServeEnabled, + tailscaleServePort, + }; - return config; - }); + return config; +}); export const resolveCliAuthConfig = ( flags: CliAuthLocationFlags, diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 6fe0304307bb..4be566c10e99 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -170,21 +170,23 @@ const resolveInstallLauncher = Effect.gen(function* () { * installs. `args` are quoted for the host's shell: POSIX, or PowerShell on * Windows. */ -export const resolveHostCliCommand = (subcommand: string, args: ReadonlyArray = []) => - Effect.gen(function* () { - const platform = yield* HostProcess.Platform; - const withArgs = (prefix: string) => - [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" "); - const command = yield* resolveCliCommand(subcommand); - if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false }; - if (yield* isCommandAvailable("t3")) return { command: withArgs(command), launcher: false }; - const launcher = yield* resolveInstallLauncher; - if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; - // PowerShell runs a quoted path only through the call operator. - const quoted = hostShellWord(platform, launcher.value); - const call = platform === "win32" ? `& ${quoted}` : quoted; - return { command: withArgs(`${call} ${subcommand}`), launcher: true }; - }); +export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand")(function* ( + subcommand: string, + args: ReadonlyArray = [], +) { + const platform = yield* HostProcess.Platform; + const withArgs = (prefix: string) => + [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" "); + const command = yield* resolveCliCommand(subcommand); + if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false }; + if (yield* isCommandAvailable("t3")) return { command: withArgs(command), launcher: false }; + const launcher = yield* resolveInstallLauncher; + if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; + // PowerShell runs a quoted path only through the call operator. + const quoted = hostShellWord(platform, launcher.value); + const call = platform === "win32" ? `& ${quoted}` : quoted; + return { command: withArgs(`${call} ${subcommand}`), launcher: true }; +}); /** * `sudo` resets PATH on most distributions, which drops a user-installed Node @@ -192,16 +194,17 @@ export const resolveHostCliCommand = (subcommand: string, args: ReadonlyArray - Effect.gen(function* () { - const { command, launcher } = yield* resolveHostCliCommand(subcommand); - if (launcher) return `sudo ${command}`; - const executablePath = yield* HostProcess.ExecutablePath; - const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => - executablePath.startsWith(`${directory}/`), - ); - return systemNode ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`; - }); +export const resolveRootCliCommand = Effect.fn("invocation.resolveRootCliCommand")(function* ( + subcommand: string, +) { + const { command, launcher } = yield* resolveHostCliCommand(subcommand); + if (launcher) return `sudo ${command}`; + const executablePath = yield* HostProcess.ExecutablePath; + const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => + executablePath.startsWith(`${directory}/`), + ); + return systemNode ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`; +}); const ROOT_PATH_DIRECTORIES = [ "/usr/local/sbin", diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 723e77144efb..073606bf6374 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -180,10 +180,12 @@ type EnvironmentProbeResult = | { readonly _tag: "unreachable" } | { readonly _tag: "not-a-t3-server" }; -const probeEnvironmentDescriptor = ( +const probeEnvironmentDescriptor: ( baseUrl: string, -): Effect.Effect => - Effect.gen(function* () { +) => Effect.Effect = Effect.fn( + "pair.probeEnvironmentDescriptor", +)( + function* (baseUrl: string) { const client = yield* HttpClient.HttpClient; const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString()); const response = yield* client.execute(request).pipe( @@ -202,7 +204,9 @@ const probeEnvironmentDescriptor = ( Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const), ); return { _tag: "descriptor", descriptor } as const; - }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); + }, + Effect.catch((outcome) => Effect.succeed(outcome)), +); interface DiscoveredPairTarget { readonly baseDir: string; diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 8f352489b885..b567a7d4d3d0 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -316,32 +316,34 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( } satisfies ProjectMutationTarget; }); -const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => - Effect.gen(function* () { +const fetchLiveOrchestrationSnapshot = Effect.fn("project.fetchLiveOrchestrationSnapshot")( + function* (origin: string, bearerToken: string) { const client = yield* makeLiveServerClient(origin); return yield* client.projects.snapshot({ headers: { authorization: `Bearer ${bearerToken}` }, }); - }).pipe( - withProjectCliLiveServerTimeout, - Effect.mapError(projectCommandErrorFromLiveServerRequest), - ); + }, + (effect, _origin, _bearerToken) => + effect.pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(projectCommandErrorFromLiveServerRequest), + ), +); -const dispatchLiveOrchestrationCommand = ( - origin: string, - bearerToken: string, - command: ProjectCliDispatchCommand, -) => - Effect.gen(function* () { +const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestrationCommand")( + function* (origin: string, bearerToken: string, command: ProjectCliDispatchCommand) { const client = yield* makeLiveServerClient(origin); yield* client.projects.mutate({ headers: { authorization: `Bearer ${bearerToken}` }, payload: command, } as Parameters[0]); - }).pipe( - withProjectCliLiveServerTimeout, - Effect.mapError(projectCommandErrorFromLiveServerRequest), - ); + }, + (effect, _origin, _bearerToken, _command) => + effect.pipe( + withProjectCliLiveServerTimeout, + Effect.mapError(projectCommandErrorFromLiveServerRequest), + ), +); const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { const projects = yield* ProjectService.ProjectService; diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index b43201cd296b..e9f0d7e19ae1 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -13,52 +13,49 @@ import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } fr const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); -const runServerCommand = ( +const runServerCommand = Effect.fn("server.runServerCommand")(function* ( flags: CliServerFlags, options?: { readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; readonly rejectRunningServer?: boolean; }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveServerConfig(flags, logLevel, options); - const run = runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); - if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) return yield* run; - return yield* Effect.raceFirst( - run, - awaitLauncherLoss().pipe( - // End like SIGTERM does (interrupt-only exit, so the runtime forces process exit). - Effect.andThen(Effect.interrupt), - ), - ); - }); +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveServerConfig(flags, logLevel, options); + const run = runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); + if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) return yield* run; + return yield* Effect.raceFirst( + run, + awaitLauncherLoss().pipe( + // End like SIGTERM does (interrupt-only exit, so the runtime forces process exit). + Effect.andThen(Effect.interrupt), + ), + ); +}); -export const runDefaultServerCommand = (flags: CliServerFlags) => - Effect.gen(function* () { - if (Option.isSome(flags.cwd)) { - const cwd = flags.cwd.value.trim(); - const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcess.Platform; - const explicitPath = - cwd === "." || - cwd === ".." || - cwd === "~" || - /[/\\]/.test(cwd) || - (platform === "win32" && /^[a-z]:/i.test(cwd)); - if ( - !explicitPath && - (!(yield* fs.exists(cwd)) || (yield* fs.stat(cwd)).type !== "Directory") - ) { - return yield* new CliError.UserError({ - cause: cwd, - userMessage: `Unknown command ${yield* encodeCommand(cwd)}. Use "t3 --help" for commands or an explicit path such as "t3 ./my-project" for a new directory.`, - }); - } +export const runDefaultServerCommand = Effect.fn("server.runDefaultServerCommand")(function* ( + flags: CliServerFlags, +) { + if (Option.isSome(flags.cwd)) { + const cwd = flags.cwd.value.trim(); + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcess.Platform; + const explicitPath = + cwd === "." || + cwd === ".." || + cwd === "~" || + /[/\\]/.test(cwd) || + (platform === "win32" && /^[a-z]:/i.test(cwd)); + if (!explicitPath && (!(yield* fs.exists(cwd)) || (yield* fs.stat(cwd)).type !== "Directory")) { + return yield* new CliError.UserError({ + cause: cwd, + userMessage: `Unknown command ${yield* encodeCommand(cwd)}. Use "t3 --help" for commands or an explicit path such as "t3 ./my-project" for a new directory.`, + }); } - return yield* runServerCommand(flags, { rejectRunningServer: true }); - }); + } + return yield* runServerCommand(flags, { rejectRunningServer: true }); +}); export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 1302b2167d0a..03fc9def0122 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -185,8 +185,9 @@ export const make = Effect.gen(function* () { yield* stopConnector(active); }); - const superviseConnector = (connector: ActiveConnector) => - Effect.gen(function* () { + // Lives until connector exit and starts replacements; avoid nested lifetime spans. + const superviseConnector = Effect.fnUntraced( + function* (connector: ActiveConnector) { const result = yield* Effect.result(connector.child.exitCode); const activeAtExit = yield* Ref.get(activeRef); if ( @@ -253,9 +254,9 @@ export const make = Effect.gen(function* () { yield* reconcileConfig(desiredConfig); }), ); - }).pipe( - Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), - ); + }, + Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), + ); // The new connector starts next to the old one on the same tunnel, and the // old one stops only once the new one registers, so clients never see the @@ -370,8 +371,9 @@ export const make = Effect.gen(function* () { yield* Effect.forkIn(retryPinnedRelayClientInstall, runtimeScope); }); - const watchConnectorRegistration = (connector: ActiveConnector) => - Effect.gen(function* () { + // Registration retries can last for the connector lifetime; keep this loop untraced. + const watchConnectorRegistration = Effect.fnUntraced( + function* (connector: ActiveConnector) { yield* Effect.sleep(CONNECTOR_REGISTRATION_TIMEOUT); if (yield* Deferred.isDone(connector.registered)) return true; yield* Effect.logWarning( @@ -384,46 +386,48 @@ export const make = Effect.gen(function* () { ); yield* Queue.offer(recoveryRequests, connector.config); return false; - }).pipe(Effect.repeat({ until: (connected) => connected })); + }, + Effect.repeat({ until: (connected) => connected }), + ); // Only an explicit 503 counts: a failed request says nothing about the - const watchConnectorReadiness = (connector: ActiveConnector) => - Effect.gen(function* () { - const readyUrl = `http://${yield* Deferred.await(connector.metricsAddress)}/ready`; - let notReadyChecks = 0; - while (true) { - yield* Effect.sleep(CONNECTOR_READINESS_INTERVAL); - const status = yield* httpClient.get(readyUrl).pipe( - Effect.map((response) => response.status), - Effect.scoped, - Effect.withTracerEnabled(false), - Effect.timeoutOption(CONNECTOR_READINESS_REQUEST_TIMEOUT), - Effect.orElseSucceed(() => Option.none()), - ); - if (Option.isNone(status)) continue; - if (status.value === 200) { - notReadyChecks = 0; - yield* Ref.set(notReadyChecksBeforeRestartRef, CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); - continue; - } - if (status.value !== 503) continue; - notReadyChecks += 1; - const threshold = yield* Ref.get(notReadyChecksBeforeRestartRef); - if (notReadyChecks < threshold) continue; + // Readiness polling runs for the connector lifetime; keep this loop untraced. + const watchConnectorReadiness = Effect.fnUntraced(function* (connector: ActiveConnector) { + const readyUrl = `http://${yield* Deferred.await(connector.metricsAddress)}/ready`; + let notReadyChecks = 0; + while (true) { + yield* Effect.sleep(CONNECTOR_READINESS_INTERVAL); + const status = yield* httpClient.get(readyUrl).pipe( + Effect.map((response) => response.status), + Effect.scoped, + Effect.withTracerEnabled(false), + Effect.timeoutOption(CONNECTOR_READINESS_REQUEST_TIMEOUT), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(status)) continue; + if (status.value === 200) { notReadyChecks = 0; - yield* Ref.set( - notReadyChecksBeforeRestartRef, - Math.min(threshold * 2, CONNECTOR_NOT_READY_CHECKS_MAX), - ); - yield* Effect.logWarning("Relay client has no tunnel connection; restarting it", { - pid: Number(connector.child.pid), - notReadyChecks: threshold, - tunnelId: connector.config.tunnelId, - tunnelName: connector.config.tunnelName, - }); - yield* connector.child.kill().pipe(Effect.ignore); + yield* Ref.set(notReadyChecksBeforeRestartRef, CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); + continue; } - }); + if (status.value !== 503) continue; + notReadyChecks += 1; + const threshold = yield* Ref.get(notReadyChecksBeforeRestartRef); + if (notReadyChecks < threshold) continue; + notReadyChecks = 0; + yield* Ref.set( + notReadyChecksBeforeRestartRef, + Math.min(threshold * 2, CONNECTOR_NOT_READY_CHECKS_MAX), + ); + yield* Effect.logWarning("Relay client has no tunnel connection; restarting it", { + pid: Number(connector.child.pid), + notReadyChecks: threshold, + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }); + yield* connector.child.kill().pipe(Effect.ignore); + } + }); const observeConnectorOutput = (connector: ActiveConnector) => { let rejectedRegistrations = 0; diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 8820fc9d442a..00ab464bea41 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -877,60 +877,59 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { Effect.withSpan("cloud.boot_service.restart"), ); - const withStopped = ( + const withStopped = Effect.fn("cloud.boot_service.with_stopped")(function* ( operation: Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - if (detectedManager === undefined) return yield* operation; - const manager = detectedManager; - const unit = yield* fs.readFileString(unitPath).pipe(Effect.option); - if (Option.isNone(unit)) return yield* operation; - const installedBaseDir = bootServiceBaseDirOf(unit.value); - if ( - installedBaseDir === undefined || - path.resolve(installedBaseDir) !== path.resolve(input.baseDir) - ) { - return yield* operation; - } - // `is-active` fails while a crash-looping unit sits in `activating - // (auto-restart)`, and a throttled launchd job is loaded without a pid; - // both would start the server mid-repair. So the unit is always - // stopped, and started again when it was active, enabled or loaded - // before; a disabled unit caught crash-looping stays stopped. - const restore = - manager.kind === "systemd" - ? yield* Effect.map( - Effect.all( - [ - probe("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]), - probe("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]), - ], - { concurrency: "unbounded" }, - ), - ([enabled, active]) => succeeded(active) || succeeded(enabled), - ) - : succeeded( - yield* probe("launchctl", ["print", `gui/${uid}/${BOOT_SERVICE_LAUNCHD_LABEL}`]), - ); - return yield* Effect.acquireUseRelease( - // Every stop step is required when the service is to be restored: a - // step that was fine to skip during uninstall would leave the server - // holding the database. A stop that fails (a timeout, say) may still - // have stopped the service, so the restore below runs either way. - // Nothing to restore means nothing is expected to be loaded, and the - // stop is best-effort (launchd's bootout fails on an unloaded job). - runSteps(manager.stop.map((step) => ({ ...step, optional: !restore }))).pipe(Effect.exit), - (stopped): Effect.Effect => - Exit.isSuccess(stopped) ? operation : Effect.failCause(stopped.cause), - (_, exit) => { - if (!restore) return Effect.void; - // Start only: `activate` would also enable a unit the user disabled. - // When the operation itself failed, its error is the one worth reporting. - const start = runSteps(manager.restart); - return Exit.isFailure(exit) ? Effect.ignore(start) : start; - }, - ); - }).pipe(Effect.withSpan("cloud.boot_service.with_stopped")); + ): Effect.fn.Return { + if (detectedManager === undefined) return yield* operation; + const manager = detectedManager; + const unit = yield* fs.readFileString(unitPath).pipe(Effect.option); + if (Option.isNone(unit)) return yield* operation; + const installedBaseDir = bootServiceBaseDirOf(unit.value); + if ( + installedBaseDir === undefined || + path.resolve(installedBaseDir) !== path.resolve(input.baseDir) + ) { + return yield* operation; + } + // `is-active` fails while a crash-looping unit sits in `activating + // (auto-restart)`, and a throttled launchd job is loaded without a pid; + // both would start the server mid-repair. So the unit is always + // stopped, and started again when it was active, enabled or loaded + // before; a disabled unit caught crash-looping stays stopped. + const restore = + manager.kind === "systemd" + ? yield* Effect.map( + Effect.all( + [ + probe("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]), + probe("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]), + ], + { concurrency: "unbounded" }, + ), + ([enabled, active]) => succeeded(active) || succeeded(enabled), + ) + : succeeded( + yield* probe("launchctl", ["print", `gui/${uid}/${BOOT_SERVICE_LAUNCHD_LABEL}`]), + ); + return yield* Effect.acquireUseRelease( + // Every stop step is required when the service is to be restored: a + // step that was fine to skip during uninstall would leave the server + // holding the database. A stop that fails (a timeout, say) may still + // have stopped the service, so the restore below runs either way. + // Nothing to restore means nothing is expected to be loaded, and the + // stop is best-effort (launchd's bootout fails on an unloaded job). + runSteps(manager.stop.map((step) => ({ ...step, optional: !restore }))).pipe(Effect.exit), + (stopped): Effect.Effect => + Exit.isSuccess(stopped) ? operation : Effect.failCause(stopped.cause), + (_, exit) => { + if (!restore) return Effect.void; + // Start only: `activate` would also enable a unit the user disabled. + // When the operation itself failed, its error is the one worth reporting. + const start = runSteps(manager.restart); + return Exit.isFailure(exit) ? Effect.ignore(start) : start; + }, + ); + }); const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { const manager = yield* requireManager; diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index acee5760952b..90713806b7cb 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -48,10 +48,10 @@ export function isAgentActivityPublishingEnabledValue(value: string | null): boo * Mirrors the per-publish gate in AgentAwarenessRelay, so the descriptor capability never advertises publishing that the publisher would skip. */ -export const readAgentActivityPublishingActive = ( +export const readAgentActivityPublishingActive: ( secrets: ServerSecretStore.ServerSecretStore["Service"], -): Effect.Effect => - Effect.gen(function* () { +) => Effect.Effect = Effect.fn("config.readAgentActivityPublishingActive")( + function* (secrets: ServerSecretStore.ServerSecretStore["Service"]) { const readSecretString = (name: string) => secrets .get(name) @@ -74,7 +74,9 @@ export const readAgentActivityPublishingActive = ( environmentCredential !== null && environmentCredential !== "" ); - }).pipe(Effect.orElseSucceed(() => false)); + }, + Effect.orElseSucceed(() => false), +); const readSecretString = (name: string) => ServerSecretStore.ServerSecretStore.pipe( diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index df683f814de2..4180d7ece6ba 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -141,34 +141,33 @@ export const withRunningThreadContinuation = Effect.fn( return ServerSelfUpdate.of({ update, - commitDesktopUpdate: (requestId) => - Effect.gen(function* () { - const shouldContinue = yield* Ref.modify(desktopContinuationTokens, (tokens) => [ - HashSet.has(tokens, requestId), - HashSet.remove(tokens, requestId), - ]); - let handoffAccepted = false; - let continuationThreadIds: ReadonlyArray = []; - return yield* clearOnError( - Effect.gen(function* () { - continuationThreadIds = shouldContinue ? yield* input.prepare : []; - return yield* input.selfUpdate.commitDesktopUpdate(requestId, () => - Effect.sync(() => { - handoffAccepted = true; - }), - ); - }), - () => continuationThreadIds, - () => handoffAccepted, - ).pipe( - Effect.catchCause((cause) => - (shouldContinue && !handoffAccepted - ? Ref.update(desktopContinuationTokens, HashSet.add(requestId)) - : Effect.void - ).pipe(Effect.andThen(Effect.failCause(cause))), - ), - ); - }), + commitDesktopUpdate: Effect.fn("selfUpdate.commitDesktopUpdate")(function* (requestId) { + const shouldContinue = yield* Ref.modify(desktopContinuationTokens, (tokens) => [ + HashSet.has(tokens, requestId), + HashSet.remove(tokens, requestId), + ]); + let handoffAccepted = false; + let continuationThreadIds: ReadonlyArray = []; + return yield* clearOnError( + Effect.gen(function* () { + continuationThreadIds = shouldContinue ? yield* input.prepare : []; + return yield* input.selfUpdate.commitDesktopUpdate(requestId, () => + Effect.sync(() => { + handoffAccepted = true; + }), + ); + }), + () => continuationThreadIds, + () => handoffAccepted, + ).pipe( + Effect.catchCause((cause) => + (shouldContinue && !handoffAccepted + ? Ref.update(desktopContinuationTokens, HashSet.add(requestId)) + : Effect.void + ).pipe(Effect.andThen(Effect.failCause(cause))), + ), + ); + }), }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index f623d3441c70..3b8ba8055ee5 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -291,10 +291,11 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = ( - scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, -) => - Effect.gen(function* () { +const authenticateRawRouteWithScope = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* ( + scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, + ) { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index 82a3aaa16eb3..f61d20b2cfd2 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -74,36 +74,37 @@ export function callAcpMcpTool( let sessionId: string | null = null; let protocolVersion: string | null = null; - const send = (message: unknown): Effect.Effect, AcpMcpBridgeError> => - Effect.gen(function* () { - const response = yield* Effect.tryPromise({ - try: () => - fetchImplementation(options.endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - authorization: options.authorization, - ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), - ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), - }, - body: JSON.stringify(message), - }), - catch: bridgeError, - }); - sessionId = response.headers.get("mcp-session-id") ?? sessionId; - if (!response.ok) { - yield* discardResponseBody(response); - return yield* Effect.fail( - new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), - ); - } - const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); - for (const payload of payloads) { - protocolVersion = protocolVersionOf(payload) ?? protocolVersion; - } - return payloads; + const send = Effect.fn("AcpMcpStdioBridge.send")(function* ( + message: unknown, + ): Effect.fn.Return, AcpMcpBridgeError> { + const response = yield* Effect.tryPromise({ + try: () => + fetchImplementation(options.endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: options.authorization, + ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), + ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), + }, + body: JSON.stringify(message), + }), + catch: bridgeError, }); + sessionId = response.headers.get("mcp-session-id") ?? sessionId; + if (!response.ok) { + yield* discardResponseBody(response); + return yield* Effect.fail( + new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), + ); + } + const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); + for (const payload of payloads) { + protocolVersion = protocolVersionOf(payload) ?? protocolVersion; + } + return payloads; + }); const initializeId = "t3-acp-cli-initialize"; const initialized = yield* send({ @@ -156,13 +157,14 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. options.output.write(`${JSON.stringify(message)}\n`); }); - const handleServerPayload = (payload: unknown): Effect.Effect => - Effect.gen(function* () { - for (const entry of Array.isArray(payload) ? payload : [payload]) { - protocolVersion = protocolVersionOf(entry) ?? protocolVersion; - yield* writeMessage(entry); - } - }); + const handleServerPayload = Effect.fn("AcpMcpStdioBridge.handleServerPayload")(function* ( + payload: unknown, + ): Effect.fn.Return { + for (const entry of Array.isArray(payload) ? payload : [payload]) { + protocolVersion = protocolVersionOf(entry) ?? protocolVersion; + yield* writeMessage(entry); + } + }); const respondWithError = ( requestId: unknown, @@ -175,8 +177,10 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. error: { code, message }, }); - const forward = (line: string, envelope: JsonRpcEnvelope): Effect.Effect => - Effect.gen(function* () { + const forward: (line: string, envelope: JsonRpcEnvelope) => Effect.Effect = Effect.fn( + "AcpMcpStdioBridge.forward", + )( + function* (line: string, envelope: JsonRpcEnvelope) { const response = yield* Effect.tryPromise({ try: () => fetchImplementation(options.endpoint, { @@ -203,16 +207,19 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. return yield* discardResponseBody(response); } yield* Stream.runForEach(responsePayloads(response, bridgeError), handleServerPayload); - }).pipe( - Effect.catchCause((cause) => { - if (envelope.id === undefined) return Effect.void; - const error = Cause.squash(cause); - return respondWithError( - envelope.id, - `T3 Code MCP bridge request failed: ${error instanceof Error ? error.message : String(error)}`, - ); - }), - ); + }, + (effect, _line, envelope) => + effect.pipe( + Effect.catchCause((cause) => { + if (envelope.id === undefined) return Effect.void; + const error = Cause.squash(cause); + return respondWithError( + envelope.id, + `T3 Code MCP bridge request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ), + ); // Only session establishment is ordered; client responses to // server-initiated SSE requests must bypass the barrier or they would @@ -221,65 +228,64 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. let sessionBarrier: Effect.Effect = Effect.void; let sessionBarrierPending = false; - const launch = (task: Effect.Effect): Effect.Effect => - Effect.gen(function* () { - const fiber = yield* Effect.forkChild(task); + const launch = Effect.fn("AcpMcpStdioBridge.launch")(function* ( + task: Effect.Effect, + ): Effect.fn.Return { + const fiber = yield* Effect.forkChild(task); + running.add(fiber); + yield* Effect.forkChild( + Fiber.await(fiber).pipe( + Effect.andThen( + Effect.sync(() => { + running.delete(fiber); + }), + ), + ), + ); + }); + + const dispatch = Effect.fn("AcpMcpStdioBridge.dispatch")(function* ( + line: string, + ): Effect.fn.Return { + if (line.trim().length === 0) return; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return yield* respondWithError(null, "Parse error", -32700); + } + const envelope = asEnvelope(parsed); + if (envelope === null) return; + const isResponse = envelope.id !== undefined && ("result" in envelope || "error" in envelope); + const isSessionHandshake = + envelope.method === "initialize" || envelope.method === "notifications/initialized"; + if (isResponse) { + return yield* launch(forward(line, envelope)); + } + if (isSessionHandshake) { + const prior = sessionBarrier; + const fiber = yield* Effect.forkChild(prior.pipe(Effect.andThen(forward(line, envelope)))); running.add(fiber); + const barrier = Fiber.await(fiber).pipe(Effect.asVoid); + sessionBarrier = barrier; + sessionBarrierPending = true; yield* Effect.forkChild( Fiber.await(fiber).pipe( Effect.andThen( Effect.sync(() => { running.delete(fiber); + if (sessionBarrier === barrier) sessionBarrierPending = false; }), ), ), ); - }); - - const dispatch = (line: string): Effect.Effect => - Effect.gen(function* () { - if (line.trim().length === 0) return; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - return yield* respondWithError(null, "Parse error", -32700); - } - const envelope = asEnvelope(parsed); - if (envelope === null) return; - const isResponse = - envelope.id !== undefined && ("result" in envelope || "error" in envelope); - const isSessionHandshake = - envelope.method === "initialize" || envelope.method === "notifications/initialized"; - if (isResponse) { - return yield* launch(forward(line, envelope)); - } - if (isSessionHandshake) { - const prior = sessionBarrier; - const fiber = yield* Effect.forkChild( - prior.pipe(Effect.andThen(forward(line, envelope))), - ); - running.add(fiber); - const barrier = Fiber.await(fiber).pipe(Effect.asVoid); - sessionBarrier = barrier; - sessionBarrierPending = true; - yield* Effect.forkChild( - Fiber.await(fiber).pipe( - Effect.andThen( - Effect.sync(() => { - running.delete(fiber); - if (sessionBarrier === barrier) sessionBarrierPending = false; - }), - ), - ), - ); - return; - } - const task = sessionBarrierPending - ? sessionBarrier.pipe(Effect.andThen(forward(line, envelope))) - : forward(line, envelope); - return yield* launch(task); - }); + return; + } + const task = sessionBarrierPending + ? sessionBarrier.pipe(Effect.andThen(forward(line, envelope))) + : forward(line, envelope); + return yield* launch(task); + }); const reader = NodeReadline.createInterface({ input: options.input }); yield* Stream.runForEach(Stream.fromAsyncIterable(reader, bridgeError), dispatch).pipe( diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index dbca27b8eb9f..2f19cc8cecf4 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -634,7 +634,11 @@ const imageToolFailure = * `McpServer.toolkit` serializes every result as JSON text, which is the wrong * shape for a screenshot: the model needs image content. */ -const registerImageTool = ( +const registerImageTool = Effect.fn("McpHttpServer.registerImageTool")(function* < + T extends Tool.Any, + E, + R, +>( tool: T, handle: (payload: Tool.Parameters) => Effect.Effect<{ readonly encodedResult: unknown }, E, R>, provide: ( @@ -646,73 +650,72 @@ const registerImageTool = ( >, operation: string, failureText: string | ((error: E) => string), -) => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - yield* server.addTool({ - tool: new McpSchema.Tool({ - name: tool.name, - description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), - annotations: { - ...Context.getOption(tool.annotations, Tool.Title).pipe( - Option.map((title) => ({ title })), - Option.getOrUndefined, - ), - readOnlyHint: Context.get(tool.annotations, Tool.Readonly), - destructiveHint: Context.get(tool.annotations, Tool.Destructive), - idempotentHint: Context.get(tool.annotations, Tool.Idempotent), - openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), - }, +) { + const server = yield* McpServer.McpServer; + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + Effect.withFiber((fiber) => { + const invocation = Context.getUnsafe( + fiber.context, + McpInvocationContext.McpInvocationContext, + ); + return provide(handle(payload as Tool.Parameters)).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.matchCauseEffect({ + onFailure: imageToolFailure(tool.name, operation, failureText), + onSuccess: ({ encodedResult }) => { + const { screenshot, ...rest } = encodedResult as ImageToolResult; + const includeImage = + (payload as { readonly includeImage?: boolean } | undefined)?.includeImage !== + false; + const metadata = { + ...rest, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; + return Effect.succeed( + new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + ...(includeImage + ? [ + { + type: "image" as const, + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ] + : []), + ], + }), + ); + }, + }), + ); }), - annotations: tool.annotations, - handle: (payload) => - Effect.withFiber((fiber) => { - const invocation = Context.getUnsafe( - fiber.context, - McpInvocationContext.McpInvocationContext, - ); - return provide(handle(payload as Tool.Parameters)).pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.matchCauseEffect({ - onFailure: imageToolFailure(tool.name, operation, failureText), - onSuccess: ({ encodedResult }) => { - const { screenshot, ...rest } = encodedResult as ImageToolResult; - const includeImage = - (payload as { readonly includeImage?: boolean } | undefined)?.includeImage !== - false; - const metadata = { - ...rest, - screenshot: { - mimeType: screenshot.mimeType, - width: screenshot.width, - height: screenshot.height, - }, - }; - return Effect.succeed( - new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - ...(includeImage - ? [ - { - type: "image" as const, - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ] - : []), - ], - }), - ); - }, - }), - ); - }), - }); }); +}); const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () { const devices = yield* DeviceService.DeviceService; @@ -820,8 +823,9 @@ const handoffTargetIds = ( * agent chose, and the provider, model, and modes of each thread that received * the work. Ids, prompts, and free text are never recorded. */ -const withToolAnalytics = (registration: Effect.Effect) => - Effect.gen(function* () { +const withToolAnalytics = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (registration: Effect.Effect) { const server = yield* McpServer.McpServer; const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService); if (Option.isNone(analytics)) return yield* registration; @@ -834,8 +838,9 @@ const withToolAnalytics = (registration: Effect.Effect) => .getThreadShell(ThreadId.make(threadId)) .pipe(Effect.orElseSucceed(() => null)); // Delegation depth: parents walked from the caller, capped. - const depthOf = (thread: AgentThread) => - Effect.gen(function* () { + const depthOf = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (thread: AgentThread) { let depth = 0; let parentId = thread.lineage.relationshipToParent === "subagent" ? thread.lineage.parentThreadId : null; @@ -868,59 +873,69 @@ const withToolAnalytics = (registration: Effect.Effect) => }), Effect.orElseSucceed(() => false), ); - const record = ( - tool: string, - args: unknown, - result: McpSchema.CallToolResult | undefined, - durationMs: number, - ) => - Effect.gen(function* () { - // Client credentials (no calling thread) report an unknown caller. - const invocation = Option.getOrUndefined( - yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext), - )?.thread; - const providers = Option.isSome(registry) ? yield* registry.value.getProviders : []; - // Only handoff tools pay for thread lookups; the rest report the - // caller's provider from the credential. - const handoff = HANDOFF_TOOLS.has(tool); - const caller = - handoff && invocation !== undefined - ? ((yield* shellOf(invocation.threadId)) ?? undefined) - : undefined; - const targetIds = handoff - ? [...new Set(handoffTargetIds(tool, args, result))].filter( - (threadId) => threadId !== invocation?.threadId, - ) - : []; - const targets = yield* Effect.forEach(targetIds, shellOf); - for (const properties of agentToolProperties({ - tool, - providers, - caller, - ...(invocation === undefined - ? {} - : { callerProviderInstanceId: invocation.providerInstanceId }), - ...(caller === undefined || invocation === undefined - ? {} - : { - callerDepth: yield* depthOf(caller), - callerScheduledRun: yield* scheduledRunOf(invocation.threadId, caller.activeRunId), - }), - targets: targets.filter((thread) => thread !== null), - outcome: toolOutcome(result), - ...(handoff ? { settings: handoffSettings(tool, args, result?.structuredContent) } : {}), - durationMs, - })) { - yield* analytics.value.record("mcp.tool.invoked", properties); - } - }).pipe(Effect.ignoreCause); + const record = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced( + function* ( + tool: string, + args: unknown, + result: McpSchema.CallToolResult | undefined, + durationMs: number, + ) { + // Client credentials (no calling thread) report an unknown caller. + const invocation = Option.getOrUndefined( + yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext), + )?.thread; + const providers = Option.isSome(registry) ? yield* registry.value.getProviders : []; + // Only handoff tools pay for thread lookups; the rest report the + // caller's provider from the credential. + const handoff = HANDOFF_TOOLS.has(tool); + const caller = + handoff && invocation !== undefined + ? ((yield* shellOf(invocation.threadId)) ?? undefined) + : undefined; + const targetIds = handoff + ? [...new Set(handoffTargetIds(tool, args, result))].filter( + (threadId) => threadId !== invocation?.threadId, + ) + : []; + const targets = yield* Effect.forEach(targetIds, shellOf); + for (const properties of agentToolProperties({ + tool, + providers, + caller, + ...(invocation === undefined + ? {} + : { callerProviderInstanceId: invocation.providerInstanceId }), + ...(caller === undefined || invocation === undefined + ? {} + : { + callerDepth: yield* depthOf(caller), + callerScheduledRun: yield* scheduledRunOf( + invocation.threadId, + caller.activeRunId, + ), + }), + targets: targets.filter((thread) => thread !== null), + outcome: toolOutcome(result), + ...(handoff + ? { settings: handoffSettings(tool, args, result?.structuredContent) } + : {}), + durationMs, + })) { + yield* analytics.value.record("mcp.tool.invoked", properties); + } + }, + (effect, _tool, _args, _result, _durationMs) => effect.pipe(Effect.ignoreCause), + ); const recordingServer = McpServer.McpServer.of({ ...server, addTool: (options) => server.addTool({ ...options, - handle: (payload) => - Effect.gen(function* () { + handle: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (payload) { const startedAt = yield* Clock.currentTimeMillis; return yield* options.handle(payload).pipe( Effect.onExit((exit) => { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5cc1fe4f128e..9e300aa23d2b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1020,35 +1020,38 @@ const make = Effect.gen(function* () { ) .pipe(Effect.mapError(threadManagementFailure)); - const loadCaller = (scope: McpInvocationScope) => - Effect.gen(function* () { - yield* requireCapability(scope); - if (scope.thread === undefined) { - return { - parent: undefined, - limits: { - runtimeMode: clientRuntimeModeCeiling(scope.client), - interactionMode: "default", - } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, - } as const; - } - const parent = yield* loadProjection(scope.thread.threadId); + const loadCaller = Effect.fn("OrchestratorMcpService.loadCaller")(function* ( + scope: McpInvocationScope, + ) { + yield* requireCapability(scope); + if (scope.thread === undefined) { return { - parent, + parent: undefined, limits: { - runtimeMode: parent.thread.runtimeMode, - interactionMode: parent.thread.interactionMode, - }, + runtimeMode: clientRuntimeModeCeiling(scope.client), + interactionMode: "default", + } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, } as const; - }); + } + const parent = yield* loadProjection(scope.thread.threadId); + return { + parent, + limits: { + runtimeMode: parent.thread.runtimeMode, + interactionMode: parent.thread.interactionMode, + }, + } as const; + }); - const loadThreadCaller = (scope: McpInvocationScope, operation: string) => - Effect.gen(function* () { - yield* requireCapability(scope); - const threadScope = yield* requireThreadScope(scope, operation); - const parent = yield* loadProjection(threadScope.thread.threadId); - return { scope: threadScope, parent } as const; - }); + const loadThreadCaller = Effect.fn("OrchestratorMcpService.loadThreadCaller")(function* ( + scope: McpInvocationScope, + operation: string, + ) { + yield* requireCapability(scope); + const threadScope = yield* requireThreadScope(scope, operation); + const parent = yield* loadProjection(threadScope.thread.threadId); + return { scope: threadScope, parent } as const; + }); const resolveProjectTarget = ( parent: Pick | undefined, @@ -1065,26 +1068,29 @@ const make = Effect.gen(function* () { ), ); - const loadTargetThread = (threadId: ThreadId) => - Effect.gen(function* () { - const shell = yield* threadManagement - .getThreadShell(threadId) - .pipe(Effect.mapError(threadManagementFailure)); - if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); - } - return yield* loadProjectThread(shell.projectId, threadId); - }); - - const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) => - Effect.gen(function* () { - const caller = yield* loadCaller(scope); - const target = - caller.parent !== undefined && threadId === caller.parent.thread.id - ? caller.parent - : yield* loadTargetThread(threadId); - return { ...caller, target } as const; - }); + const loadTargetThread = Effect.fn("OrchestratorMcpService.loadTargetThread")(function* ( + threadId: ThreadId, + ) { + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); + } + return yield* loadProjectThread(shell.projectId, threadId); + }); + + const loadScopedThread = Effect.fn("OrchestratorMcpService.loadScopedThread")(function* ( + scope: McpInvocationScope, + threadId: ThreadId, + ) { + const caller = yield* loadCaller(scope); + const target = + caller.parent !== undefined && threadId === caller.parent.thread.id + ? caller.parent + : yield* loadTargetThread(threadId); + return { ...caller, target } as const; + }); /** * A thread caller writes to another thread only while its own run is live, @@ -1135,24 +1141,26 @@ const make = Effect.gen(function* () { : Effect.void; }; - const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) => - Effect.gen(function* () { - const { parent } = yield* loadCaller(scope); - const shell = yield* threadManagement - .getThreadShell(threadId) - .pipe(Effect.mapError(threadManagementFailure)); - if (shell === null || shell.deletedAt !== null) { - return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); - } - const target = yield* threadManagement - .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [ - "runs", - "runtimeRequests", - "contextTransfers", - ]) - .pipe(Effect.mapError(threadManagementFailure)); - return { parent, target, shell } as const; - }); + const loadReadableThread = Effect.fn("OrchestratorMcpService.loadReadableThread")(function* ( + scope: McpInvocationScope, + threadId: ThreadId, + ) { + const { parent } = yield* loadCaller(scope); + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if (shell === null || shell.deletedAt !== null) { + return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`); + } + const target = yield* threadManagement + .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [ + "runs", + "runtimeRequests", + "contextTransfers", + ]) + .pipe(Effect.mapError(threadManagementFailure)); + return { parent, target, shell } as const; + }); const loadProviders = providerRegistry.getProviders; @@ -1196,7 +1204,7 @@ const make = Effect.gen(function* () { ); }; - const resolveTarget = (input: { + const resolveTarget = Effect.fn("OrchestratorMcpService.resolveTarget")(function* (input: { readonly parent: Pick; readonly target: OrchestratorMcpTarget | undefined; readonly providers: ReadonlyArray; @@ -1208,327 +1216,317 @@ const make = Effect.gen(function* () { * one checked for a receipt. */ readonly skipProjectEnablementFilter?: boolean; - }): Effect.Effect => - Effect.gen(function* () { - const requestedInstanceId = input.target?.providerInstanceId; - const requestedDriver = input.target?.driverKind; - const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); - let instanceId = requestedInstanceId; - - if (instanceId === undefined && requestedDriver !== undefined) { - const candidates = input.providers.filter( - (provider) => - provider.driver === requestedDriver && - orchestrationCapableInstanceIds.has(provider.instanceId), - ); - if (candidates.length === 0) { - return yield* failure( - "provider_unavailable", - `No V2 provider adapter is registered for driver ${requestedDriver}.`, - ); - } - const healthyCandidates = candidates.filter( - (candidate) => providerConstraints(candidate, true).length === 0, - ); - const inheritedCandidate = healthyCandidates.find( - (candidate) => candidate.instanceId === input.parent.thread.modelSelection.instanceId, - ); - const orderedCandidates = [ - ...(inheritedCandidate === undefined ? [] : [inheritedCandidate]), - ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate), - ]; - for (const candidate of orderedCandidates) { - const enabledForProject = - input.skipProjectEnablementFilter === true || - (yield* threadManagement.isProviderInstanceEnabledForProject({ - projectId: input.parent.thread.projectId, - instanceId: candidate.instanceId, - })); - if (enabledForProject) { - instanceId = candidate.instanceId; - break; - } - } - if (instanceId === undefined) { - return yield* failure( - "provider_unavailable", - healthyCandidates.length === 0 - ? `No available V2 provider instance for driver ${requestedDriver}.` - : `No instance of driver ${requestedDriver} is enabled for this project.`, - ); - } - } - instanceId ??= input.parent.thread.modelSelection.instanceId; - - const provider = input.providers.find((candidate) => candidate.instanceId === instanceId); - if (provider === undefined) { + }): Effect.fn.Return { + const requestedInstanceId = input.target?.providerInstanceId; + const requestedDriver = input.target?.driverKind; + const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); + let instanceId = requestedInstanceId; + + if (instanceId === undefined && requestedDriver !== undefined) { + const candidates = input.providers.filter( + (provider) => + provider.driver === requestedDriver && + orchestrationCapableInstanceIds.has(provider.instanceId), + ); + if (candidates.length === 0) { return yield* failure( "provider_unavailable", - `Provider instance ${instanceId} is not registered.`, - ); - } - if (requestedDriver !== undefined && provider.driver !== requestedDriver) { - return yield* failure( - "invalid_request", - `Provider instance ${instanceId} uses driver ${provider.driver}, not ${requestedDriver}.`, + `No V2 provider adapter is registered for driver ${requestedDriver}.`, ); } - const constraints = providerConstraints( - provider, - orchestrationCapableInstanceIds.has(provider.instanceId), + const healthyCandidates = candidates.filter( + (candidate) => providerConstraints(candidate, true).length === 0, ); - if (constraints.length > 0) { + const inheritedCandidate = healthyCandidates.find( + (candidate) => candidate.instanceId === input.parent.thread.modelSelection.instanceId, + ); + const orderedCandidates = [ + ...(inheritedCandidate === undefined ? [] : [inheritedCandidate]), + ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate), + ]; + for (const candidate of orderedCandidates) { + const enabledForProject = + input.skipProjectEnablementFilter === true || + (yield* threadManagement.isProviderInstanceEnabledForProject({ + projectId: input.parent.thread.projectId, + instanceId: candidate.instanceId, + })); + if (enabledForProject) { + instanceId = candidate.instanceId; + break; + } + } + if (instanceId === undefined) { return yield* failure( "provider_unavailable", - `Provider ${instanceId} cannot run a child task: ${constraints.join(" ")}`, + healthyCandidates.length === 0 + ? `No available V2 provider instance for driver ${requestedDriver}.` + : `No instance of driver ${requestedDriver} is enabled for this project.`, ); } + } + instanceId ??= input.parent.thread.modelSelection.instanceId; - const inheritedSelection = input.parent.thread.modelSelection; - const requestedModel = input.target?.model; - const model = - requestedModel ?? - (instanceId === inheritedSelection.instanceId - ? inheritedSelection.model - : provider?.models[0]?.slug); - if (model === undefined) { - return yield* failure( - "model_unavailable", - `Provider ${instanceId} has no model available for inheritance.`, - ); - } - if ( - requestedModel !== undefined && - provider !== undefined && - provider.models.length > 0 && - !provider.models.some((candidate) => candidate.slug === requestedModel) - ) { + const provider = input.providers.find((candidate) => candidate.instanceId === instanceId); + if (provider === undefined) { + return yield* failure( + "provider_unavailable", + `Provider instance ${instanceId} is not registered.`, + ); + } + if (requestedDriver !== undefined && provider.driver !== requestedDriver) { + return yield* failure( + "invalid_request", + `Provider instance ${instanceId} uses driver ${provider.driver}, not ${requestedDriver}.`, + ); + } + const constraints = providerConstraints( + provider, + orchestrationCapableInstanceIds.has(provider.instanceId), + ); + if (constraints.length > 0) { + return yield* failure( + "provider_unavailable", + `Provider ${instanceId} cannot run a child task: ${constraints.join(" ")}`, + ); + } + + const inheritedSelection = input.parent.thread.modelSelection; + const requestedModel = input.target?.model; + const model = + requestedModel ?? + (instanceId === inheritedSelection.instanceId + ? inheritedSelection.model + : provider?.models[0]?.slug); + if (model === undefined) { + return yield* failure( + "model_unavailable", + `Provider ${instanceId} has no model available for inheritance.`, + ); + } + if ( + requestedModel !== undefined && + provider !== undefined && + provider.models.length > 0 && + !provider.models.some((candidate) => candidate.slug === requestedModel) + ) { + return yield* failure( + "model_unavailable", + `Model ${requestedModel} is not advertised by provider ${instanceId}.`, + ); + } + + const requestedOptions = input.target?.options; + if (requestedOptions !== undefined) { + const descriptors = provider.models.find((candidate) => candidate.slug === model) + ?.capabilities?.optionDescriptors; + const invalid = invalidOptionSelections(requestedOptions, descriptors); + if (invalid.length > 0) { return yield* failure( - "model_unavailable", - `Model ${requestedModel} is not advertised by provider ${instanceId}.`, + "invalid_request", + `Model ${model} on provider ${instanceId} rejected options: ${invalid.join(" ")}`, ); } + } - const requestedOptions = input.target?.options; - if (requestedOptions !== undefined) { - const descriptors = provider.models.find((candidate) => candidate.slug === model) - ?.capabilities?.optionDescriptors; - const invalid = invalidOptionSelections(requestedOptions, descriptors); - if (invalid.length > 0) { - return yield* failure( - "invalid_request", - `Model ${model} on provider ${instanceId} rejected options: ${invalid.join(" ")}`, - ); - } - } - - return { - modelSelection: - instanceId === inheritedSelection.instanceId && - model === inheritedSelection.model && - requestedOptions === undefined - ? inheritedSelection - : requestedOptions === undefined - ? { instanceId, model } - : { instanceId, model, options: requestedOptions }, - supportedRuntimeModes: provider.supportedRuntimeModes, - }; - }); + return { + modelSelection: + instanceId === inheritedSelection.instanceId && + model === inheritedSelection.model && + requestedOptions === undefined + ? inheritedSelection + : requestedOptions === undefined + ? { instanceId, model } + : { instanceId, model, options: requestedOptions }, + supportedRuntimeModes: provider.supportedRuntimeModes, + }; + }); const requestKey = (clientRequestId: string | undefined): Effect.Effect => clientRequestId === undefined ? crypto.randomUUIDv4().pipe(Effect.orDie) : Effect.succeed(clientRequestId); - const readTask = ( + const readTask = Effect.fn("OrchestratorMcpService.readTask")(function* ( scope: McpThreadInvocationScope, taskId: NodeId, waitTimedOut = false, acknowledgeTerminal = false, acknowledgementOperation = "task-status-acknowledge", - ): Effect.Effect => - Effect.gen(function* () { - yield* requireCapability(scope); - const parentProjection = yield* loadProjection(scope.thread.threadId); - const task = parentProjection.subagents.find( - (candidate) => - candidate.id === taskId && - candidate.origin === "app_owned" && - candidate.threadId === scope.thread.threadId, + ): Effect.fn.Return { + yield* requireCapability(scope); + const parentProjection = yield* loadProjection(scope.thread.threadId); + const task = parentProjection.subagents.find( + (candidate) => + candidate.id === taskId && + candidate.origin === "app_owned" && + candidate.threadId === scope.thread.threadId, + ); + if (task === undefined || task.childThreadId === null) { + return yield* failure( + "task_not_found", + `Delegated task ${taskId} does not belong to thread ${scope.thread.threadId}.`, ); - if (task === undefined || task.childThreadId === null) { - return yield* failure( - "task_not_found", - `Delegated task ${taskId} does not belong to thread ${scope.thread.threadId}.`, - ); - } - const childControls = yield* threadManagement - .getThreadRecords( - task.childThreadId, - [ - "runs", - "messages", - "contextTransfers", - "subagents", - "providerThreads", - "runtimeRequests", - ], - { messageRoles: ["user"] }, - ) - .pipe(Effect.mapError(threadManagementFailure)); - const childRun = delegatedTaskRun(childControls, task); - const terminalRun = latestTerminalResultRun(childControls, childRun); - const progress = delegatedTaskProgress(childControls); - const taskResultRun = - childRun?.delegatedTaskId === task.id - ? childControls.runs - .filter((run) => run.delegatedTaskId === task.id) - .toSorted((a, b) => b.ordinal - a.ordinal)[0] - : progress.resultRun; - const taskWorkState = - childRun?.delegatedTaskId === task.id - ? isTerminalTaskStatus(taskStatusForRun(taskResultRun)) - ? "result_available" - : "working" - : progress.state; - const resultRunIds = [ - ...new Set( - [taskResultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined), + } + const childControls = yield* threadManagement + .getThreadRecords( + task.childThreadId, + ["runs", "messages", "contextTransfers", "subagents", "providerThreads", "runtimeRequests"], + { messageRoles: ["user"] }, + ) + .pipe(Effect.mapError(threadManagementFailure)); + const childRun = delegatedTaskRun(childControls, task); + const terminalRun = latestTerminalResultRun(childControls, childRun); + const progress = delegatedTaskProgress(childControls); + const taskResultRun = + childRun?.delegatedTaskId === task.id + ? childControls.runs + .filter((run) => run.delegatedTaskId === task.id) + .toSorted((a, b) => b.ordinal - a.ordinal)[0] + : progress.resultRun; + const taskWorkState = + childRun?.delegatedTaskId === task.id + ? isTerminalTaskStatus(taskStatusForRun(taskResultRun)) + ? "result_available" + : "working" + : progress.state; + const resultRunIds = [ + ...new Set( + [taskResultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined), + ), + ]; + const resultRecords = yield* threadManagement + .getThreadRecords(task.childThreadId, ["messages", "turnItems"], { + messageRoles: ["assistant"], + messageRunIds: resultRunIds, + turnItemRunIds: resultRunIds, + turnItemTypes: ["assistant_message", "error"], + }) + .pipe(Effect.mapError(threadManagementFailure)); + const childProjection = { + ...childControls, + messages: [...childControls.messages, ...resultRecords.messages], + turnItems: resultRecords.turnItems, + }; + const heldForRestart = + task.result === null && + taskWorkState === "result_available" && + (yield* threadManagement + .delegatedTaskResultPending(task.childThreadId, childRun?.delegatedTaskId) + .pipe(Effect.mapError(threadManagementFailure))); + const workState = + task.result !== null ? "result_available" : heldForRestart ? "working" : taskWorkState; + const status = + task.result !== null + ? taskStatusForRun( + task.status === "completed" || + task.status === "failed" || + task.status === "cancelled" || + task.status === "interrupted" + ? { status: task.status } + : childRun, + ) + : workState === "result_available" + ? taskStatusForRun(taskResultRun ?? childRun) + : taskStatusForRun(taskResultRun ?? childRun) === "queued" + ? "queued" + : "running"; + const derivedResult = + task.result !== null + ? task.result + : taskResultRun !== undefined && isTerminalTaskStatus(status) + ? subagentResultForRun(childProjection, taskResultRun).text + : null; + const resultTransfers = parentProjection.contextTransfers.filter( + (transfer) => + transfer.type === "subagent_result" && + transfer.sourceThreadId === task.childThreadId && + transfer.targetThreadId === scope.thread.threadId, + ); + const resultTransferForRun = (run: OrchestrationV2Run | undefined) => + !canExposeTaskRunResult(run) + ? null + : (resultTransfers.find((transfer) => transfer.sourcePoint.runId === run.id) ?? + (run.id === childRun?.id + ? resultTransfers.find((transfer) => transfer.sourcePoint.runId === undefined) + : undefined) ?? + null); + const resultTransfer = taskResultTransfer(parentProjection, childProjection, task) ?? null; + const terminalStatus = terminalRun === undefined ? null : taskStatusForRun(terminalRun); + const latestTerminalSummary = canExposeTaskRunResult(terminalRun) + ? terminalRun.id === childRun?.id + ? derivedResult + : subagentResultForRun(childProjection, terminalRun).text + : null; + const resultContextTransferId = resultTransfer?.id ?? null; + const latestTerminalResultContextTransferId = resultTransferForRun(terminalRun)?.id ?? null; + const response = { + taskId: task.id, + childThreadId: task.childThreadId, + childRunId: childRun?.id ?? null, + childNodeId: task.id, + status, + workState, + hasPendingChildRuns: hasPendingChildRuns(childProjection, childRun), + providerInstanceId: task.providerInstanceId, + model: task.model, + summary: derivedResult, + resultContextTransferId, + latestTerminalRunId: terminalRun?.id ?? null, + latestTerminalStatus: + terminalStatus !== null && isTerminalTaskStatus(terminalStatus) ? terminalStatus : null, + latestTerminalSummary: latestTerminalSummary === derivedResult ? null : latestTerminalSummary, + latestTerminalResultContextTransferId: + latestTerminalResultContextTransferId === resultContextTransferId + ? null + : latestTerminalResultContextTransferId, + hasPendingQuestion: + !isTerminalTaskStatus(status) && + childControls.runtimeRequests.some( + (request) => + request.kind === "user_input" && + request.status === "pending" && + request.responseCapability.type === "live", ), - ]; - const resultRecords = yield* threadManagement - .getThreadRecords(task.childThreadId, ["messages", "turnItems"], { - messageRoles: ["assistant"], - messageRunIds: resultRunIds, - turnItemRunIds: resultRunIds, - turnItemTypes: ["assistant_message", "error"], + waitTimedOut, + } satisfies OrchestratorMcpDelegateTaskResult; + if ( + acknowledgeTerminal && + isTerminalTaskStatus(status) && + task.completionDelivery?.state !== "acknowledged" && + task.completionDelivery?.state !== "disposed" + ) { + const observingRun = ThreadManagementService.latestActiveRun(parentProjection); + const acknowledgementRequestKey = yield* requestKey(undefined); + yield* threadManagement + .dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: stableCommandId({ + scope, + requestKey: acknowledgementRequestKey, + operation: acknowledgementOperation, + }), + parentThreadId: scope.thread.threadId, + taskId, + observedByRunId: + observingRun?.providerInstanceId === scope.thread.providerInstanceId + ? observingRun.id + : null, }) - .pipe(Effect.mapError(threadManagementFailure)); - const childProjection = { - ...childControls, - messages: [...childControls.messages, ...resultRecords.messages], - turnItems: resultRecords.turnItems, - }; - const heldForRestart = - task.result === null && - taskWorkState === "result_available" && - (yield* threadManagement - .delegatedTaskResultPending(task.childThreadId, childRun?.delegatedTaskId) - .pipe(Effect.mapError(threadManagementFailure))); - const workState = - task.result !== null ? "result_available" : heldForRestart ? "working" : taskWorkState; - const status = - task.result !== null - ? taskStatusForRun( - task.status === "completed" || - task.status === "failed" || - task.status === "cancelled" || - task.status === "interrupted" - ? { status: task.status } - : childRun, - ) - : workState === "result_available" - ? taskStatusForRun(taskResultRun ?? childRun) - : taskStatusForRun(taskResultRun ?? childRun) === "queued" - ? "queued" - : "running"; - const derivedResult = - task.result !== null - ? task.result - : taskResultRun !== undefined && isTerminalTaskStatus(status) - ? subagentResultForRun(childProjection, taskResultRun).text - : null; - const resultTransfers = parentProjection.contextTransfers.filter( - (transfer) => - transfer.type === "subagent_result" && - transfer.sourceThreadId === task.childThreadId && - transfer.targetThreadId === scope.thread.threadId, - ); - const resultTransferForRun = (run: OrchestrationV2Run | undefined) => - !canExposeTaskRunResult(run) - ? null - : (resultTransfers.find((transfer) => transfer.sourcePoint.runId === run.id) ?? - (run.id === childRun?.id - ? resultTransfers.find((transfer) => transfer.sourcePoint.runId === undefined) - : undefined) ?? - null); - const resultTransfer = taskResultTransfer(parentProjection, childProjection, task) ?? null; - const terminalStatus = terminalRun === undefined ? null : taskStatusForRun(terminalRun); - const latestTerminalSummary = canExposeTaskRunResult(terminalRun) - ? terminalRun.id === childRun?.id - ? derivedResult - : subagentResultForRun(childProjection, terminalRun).text - : null; - const resultContextTransferId = resultTransfer?.id ?? null; - const latestTerminalResultContextTransferId = resultTransferForRun(terminalRun)?.id ?? null; - const response = { - taskId: task.id, - childThreadId: task.childThreadId, - childRunId: childRun?.id ?? null, - childNodeId: task.id, - status, - workState, - hasPendingChildRuns: hasPendingChildRuns(childProjection, childRun), - providerInstanceId: task.providerInstanceId, - model: task.model, - summary: derivedResult, - resultContextTransferId, - latestTerminalRunId: terminalRun?.id ?? null, - latestTerminalStatus: - terminalStatus !== null && isTerminalTaskStatus(terminalStatus) ? terminalStatus : null, - latestTerminalSummary: - latestTerminalSummary === derivedResult ? null : latestTerminalSummary, - latestTerminalResultContextTransferId: - latestTerminalResultContextTransferId === resultContextTransferId - ? null - : latestTerminalResultContextTransferId, - hasPendingQuestion: - !isTerminalTaskStatus(status) && - childControls.runtimeRequests.some( - (request) => - request.kind === "user_input" && - request.status === "pending" && - request.responseCapability.type === "live", - ), - waitTimedOut, - } satisfies OrchestratorMcpDelegateTaskResult; - if ( - acknowledgeTerminal && - isTerminalTaskStatus(status) && - task.completionDelivery?.state !== "acknowledged" && - task.completionDelivery?.state !== "disposed" - ) { - const observingRun = ThreadManagementService.latestActiveRun(parentProjection); - const acknowledgementRequestKey = yield* requestKey(undefined); - yield* threadManagement - .dispatch({ - type: "delegated_task.completion-delivery.acknowledge", - commandId: stableCommandId({ - scope, - requestKey: acknowledgementRequestKey, - operation: acknowledgementOperation, - }), - parentThreadId: scope.thread.threadId, - taskId, - observedByRunId: - observingRun?.providerInstanceId === scope.thread.providerInstanceId - ? observingRun.id - : null, - }) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to acknowledge delegated task ${taskId}: ${errorMessage(error)}`, - ), + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to acknowledge delegated task ${taskId}: ${errorMessage(error)}`, ), - ); - } - return response; - }); + ), + ); + } + return response; + }); - const waitForTask = (scope: McpThreadInvocationScope, taskId: NodeId, timeoutMs: number) => - Effect.gen(function* () { + const waitForTask = Effect.fn("OrchestratorMcpService.waitForTask")( + function* (scope: McpThreadInvocationScope, taskId: NodeId, _timeoutMs: number) { const streamError = (error: unknown) => failure( "orchestration_error", @@ -1559,17 +1557,21 @@ const make = Effect.gen(function* () { Stream.filter((result) => result.hasPendingQuestion || isTerminalTaskStatus(result.status)), Stream.runHead, ); - }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten)); + }, + (effect, _scope, _taskId, timeoutMs) => + effect.pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten)), + ); - const scheduledTaskRunModes = (task: ScheduledTask) => - Effect.gen(function* () { + const scheduledTaskRunModes = Effect.fn("OrchestratorMcpService.scheduledTaskRunModes")( + function* (task: ScheduledTask) { const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }]; if (task.threadId === null) return modes; const bound = yield* threadManagement .getThreadShell(task.threadId) .pipe(Effect.mapError(threadManagementFailure)); return bound === null || bound.deletedAt !== null ? modes : [...modes, bound]; - }); + }, + ); const withinLimits = ( limits: { @@ -1586,18 +1588,18 @@ const make = Effect.gen(function* () { * approved for read-only access, and a thread caller only with a live turn, at modes covering * every mode the task runs at. */ - const summarizeScheduledTask = ( - scope: McpInvocationScope, - caller: { - readonly parent: Pick | undefined; - readonly limits: { - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; - }; - }, - task: ScheduledTask, - ) => - Effect.gen(function* () { + const summarizeScheduledTask = Effect.fn("OrchestratorMcpService.summarizeScheduledTask")( + function* ( + scope: McpInvocationScope, + caller: { + readonly parent: Pick | undefined; + readonly limits: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }; + }, + task: ScheduledTask, + ) { const live = caller.parent === undefined ? scope.client?.access !== "read-only" @@ -1609,101 +1611,97 @@ const make = Effect.gen(function* () { Option.isSome(modes) && modes.value.every((mode) => withinLimits(caller.limits, mode)), ); - }); + }, + ); /** * A scheduled task the caller may change: one whose runs execute at modes no * broader than the caller's own, so editing its prompt cannot run work above * the caller's limits. */ - const loadScheduledTask = ( + const loadScheduledTask = Effect.fn("OrchestratorMcpService.loadScheduledTask")(function* ( scheduledTaskId: ScheduledTask["id"], limits: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; }, - ): Effect.Effect => - Effect.gen(function* () { - const { tasks } = yield* scheduledTasks - .list() + ): Effect.fn.Return { + const { tasks } = yield* scheduledTasks + .list() + .pipe( + Effect.mapError((error) => + failure("orchestration_error", `Could not load scheduled task: ${error.message}`), + ), + ); + const task = tasks.find((candidate) => candidate.id === scheduledTaskId); + if (task === undefined) { + return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`); + } + for (const modes of yield* scheduledTaskRunModes(task)) { + yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode); + } + return task; + }); + + return OrchestratorMcpService.of({ + scheduleTask: Effect.fn("OrchestratorMcpService.scheduleTask")(function* (scope, input) { + const { parent, limits } = yield* loadCaller(scope); + const projectId = yield* resolveProjectTarget(parent, input.projectId); + yield* assertLiveCallerForOtherProject(scope, parent, projectId); + const project = yield* requireProject(projectId); + const bindToCurrentThread = + input.bindToCurrentThread ?? + (parent !== undefined && parent.thread.projectId === projectId); + if (bindToCurrentThread && (parent === undefined || parent.thread.projectId !== projectId)) { + return yield* failure( + "invalid_request", + parent === undefined + ? "bindToCurrentThread needs an agent running inside a T3 thread." + : "bindToCurrentThread binds to this thread, which belongs to a different project.", + ); + } + const modelSelection = + parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project)); + const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? ""; + const title = + input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task"); + const upsertInput: ScheduledTaskUpsertInput = { + title, + prompt: input.prompt, + enabled: input.enabled ?? true, + schedule: input.schedule, + projectId, + threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null, + workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread), + modelSelection, + runtimeMode: limits.runtimeMode, + interactionMode: limits.interactionMode, + createdBy: "agent", + creationSource: "mcp", + // Scope the idempotency key by provider session so two callers + // reusing the same clientRequestId cannot collide on one task row. + ...(input.clientRequestId === undefined + ? {} + : { + commandId: stableCommandId({ + scope, + requestKey: input.clientRequestId, + operation: "schedule-task", + }), + }), + }; + const { task } = yield* scheduledTasks + .upsert(upsertInput) .pipe( Effect.mapError((error) => - failure("orchestration_error", `Could not load scheduled task: ${error.message}`), + failure("orchestration_error", `Could not schedule task: ${error.message}`), ), ); - const task = tasks.find((candidate) => candidate.id === scheduledTaskId); - if (task === undefined) { - return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`); - } - for (const modes of yield* scheduledTaskRunModes(task)) { - yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode); - } - return task; - }); - - return OrchestratorMcpService.of({ - scheduleTask: (scope, input) => - Effect.gen(function* () { - const { parent, limits } = yield* loadCaller(scope); - const projectId = yield* resolveProjectTarget(parent, input.projectId); - yield* assertLiveCallerForOtherProject(scope, parent, projectId); - const project = yield* requireProject(projectId); - const bindToCurrentThread = - input.bindToCurrentThread ?? - (parent !== undefined && parent.thread.projectId === projectId); - if ( - bindToCurrentThread && - (parent === undefined || parent.thread.projectId !== projectId) - ) { - return yield* failure( - "invalid_request", - parent === undefined - ? "bindToCurrentThread needs an agent running inside a T3 thread." - : "bindToCurrentThread binds to this thread, which belongs to a different project.", - ); - } - const modelSelection = - parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project)); - const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? ""; - const title = - input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task"); - const upsertInput: ScheduledTaskUpsertInput = { - title, - prompt: input.prompt, - enabled: input.enabled ?? true, - schedule: input.schedule, - projectId, - threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null, - workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread), - modelSelection, - runtimeMode: limits.runtimeMode, - interactionMode: limits.interactionMode, - createdBy: "agent", - creationSource: "mcp", - // Scope the idempotency key by provider session so two callers - // reusing the same clientRequestId cannot collide on one task row. - ...(input.clientRequestId === undefined - ? {} - : { - commandId: stableCommandId({ - scope, - requestKey: input.clientRequestId, - operation: "schedule-task", - }), - }), - }; - const { task } = yield* scheduledTasks - .upsert(upsertInput) - .pipe( - Effect.mapError((error) => - failure("orchestration_error", `Could not schedule task: ${error.message}`), - ), - ); - return yield* summarizeScheduledTask(scope, { parent, limits }, task); - }), - listScheduledTasks: (scope, input) => - Effect.gen(function* () { + return yield* summarizeScheduledTask(scope, { parent, limits }, task); + }), + listScheduledTasks: Effect.fn("OrchestratorMcpService.listScheduledTasks")( + function* (scope, input) { const { parent, limits } = yield* loadCaller(scope); const projectId = input.projectId ?? parent?.thread.projectId; const { tasks } = yield* scheduledTasks @@ -1719,9 +1717,10 @@ const make = Effect.gen(function* () { (task) => summarizeScheduledTask(scope, { parent, limits }, task), ), }; - }), - updateScheduledTask: (scope, input) => - Effect.gen(function* () { + }, + ), + updateScheduledTask: Effect.fn("OrchestratorMcpService.updateScheduledTask")( + function* (scope, input) { const { parent, limits } = yield* loadCaller(scope); const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); @@ -1772,9 +1771,10 @@ const make = Effect.gen(function* () { ), ); return yield* summarizeScheduledTask(scope, { parent, limits }, task); - }), - deleteScheduledTask: (scope, input) => - Effect.gen(function* () { + }, + ), + deleteScheduledTask: Effect.fn("OrchestratorMcpService.deleteScheduledTask")( + function* (scope, input) { const { parent, limits } = yield* loadCaller(scope); const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); @@ -1786,545 +1786,541 @@ const make = Effect.gen(function* () { ), ); return { scheduledTaskId: existing.id, deleted: true }; - }), - requestSecret: (scope, input) => - Effect.gen(function* () { - const { scope: threadScope, parent } = yield* loadThreadCaller(scope, "request_secret"); - const threadId = threadScope.thread.threadId; - const run = ThreadManagementService.latestActiveRun(parent); - if ( - run === undefined || - run.rootNodeId === null || - run.providerInstanceId !== threadScope.thread.providerInstanceId - ) { - return yield* failure( - "parent_not_active", - "Asking for a secret requires an active run owned by this MCP provider session.", - ); - } - const runId = run.id; - const nodeId = run.rootNodeId; - const key = yield* requestKey(input.clientRequestId); - const turnItemId = TurnItemId.make( - `turn-item:secret-request:${stablePart(threadId)}:${stablePart(key)}`, + }, + ), + requestSecret: Effect.fn("OrchestratorMcpService.requestSecret")(function* (scope, input) { + const { scope: threadScope, parent } = yield* loadThreadCaller(scope, "request_secret"); + const threadId = threadScope.thread.threadId; + const run = ThreadManagementService.latestActiveRun(parent); + if ( + run === undefined || + run.rootNodeId === null || + run.providerInstanceId !== threadScope.thread.providerInstanceId + ) { + return yield* failure( + "parent_not_active", + "Asking for a secret requires an active run owned by this MCP provider session.", ); - const record = (secretStatus: "pending" | "cancelled") => - threadManagement - .dispatch({ - type: "secret_request.record", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: `secret-${secretStatus}`, - }), - threadId: threadId, - runId, - nodeId, - turnItemId, - label: input.label, - reason: input.reason, - ...(input.placeholder === undefined ? {} : { placeholder: input.placeholder }), - secretStatus, + } + const runId = run.id; + const nodeId = run.rootNodeId; + const key = yield* requestKey(input.clientRequestId); + const turnItemId = TurnItemId.make( + `turn-item:secret-request:${stablePart(threadId)}:${stablePart(key)}`, + ); + const record = (secretStatus: "pending" | "cancelled") => + threadManagement + .dispatch({ + type: "secret_request.record", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: `secret-${secretStatus}`, + }), + threadId: threadId, + runId, + nodeId, + turnItemId, + label: input.label, + reason: input.reason, + ...(input.placeholder === undefined ? {} : { placeholder: input.placeholder }), + secretStatus, + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Could not record the secret request: ${errorMessage(error)}`, + ), + ), + ); + yield* record("pending"); + // Only this call can hand the agent its ref, so the card must not + // outlive it: a timeout, a failed wait or an aborted call closes it as + // cancelled. + const closeCard = record("cancelled").pipe( + Effect.catch((error) => + Effect.logWarning("Could not close a secret request card", { error: error.message }), + ), + ); + + const answered = yield* Effect.gen(function* () { + while (true) { + const projection = yield* threadManagement + .getThreadRecords(threadId, ["runs", "turnItems"], { + turnItemTypes: ["secret_request"], + messageRoles: [], }) .pipe( Effect.mapError((error) => failure( "orchestration_error", - `Could not record the secret request: ${errorMessage(error)}`, + `Unable to read the secret request: ${errorMessage(error)}`, ), ), ); - yield* record("pending"); - // Only this call can hand the agent its ref, so the card must not - // outlive it: a timeout, a failed wait or an aborted call closes it as - // cancelled. - const closeCard = record("cancelled").pipe( - Effect.catch((error) => - Effect.logWarning("Could not close a secret request card", { error: error.message }), + const item = projection.turnItems.find((candidate) => candidate.id === turnItemId); + if (item?.type === "secret_request" && item.secretStatus !== "pending") { + return item.secretStatus; + } + const current = projection.runs.find((candidate) => candidate.id === runId); + if ( + current === undefined || + ThreadManagementService.isTerminalRunStatus(current.status) + ) { + yield* record("cancelled"); + return "cancelled" as const; + } + yield* Effect.sleep(Duration.millis(SECRET_REQUEST_POLL_INTERVAL_MS)); + } + }).pipe( + Effect.timeoutOption( + Duration.millis( + Math.min(input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), ), + ), + Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : closeCard)), + ); + if (Option.isNone(answered)) yield* closeCard; + const status = Option.isSome(answered) + ? answered.value + : yield* threadManagement + .getThreadRecords(threadId, ["turnItems"], { + turnItemTypes: ["secret_request"], + messageRoles: [], + }) + .pipe( + Effect.map((records) => { + const item = records.turnItems.find((candidate) => candidate.id === turnItemId); + return item?.type === "secret_request" && + (item.secretStatus === "saved" || item.secretStatus === "declined") + ? item.secretStatus + : ("timed_out" as const); + }), + Effect.orElseSucceed(() => "timed_out" as const), + ); + yield* Effect.annotateCurrentSpan({ "secret_request.status": status }); + yield* Metrics.increment(Metrics.secretRequestsTotal, { status }); + if (status !== "saved") return { status }; + const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId }); + if (Option.isNone(secretRef)) { + return yield* failure( + "orchestration_error", + "The user saved the secret, but it could not be read. Ask again with a new clientRequestId.", ); + } + return { status, secretRef: secretRef.value }; + }), - const answered = yield* Effect.gen(function* () { - while (true) { - const projection = yield* threadManagement - .getThreadRecords(threadId, ["runs", "turnItems"], { - turnItemTypes: ["secret_request"], - messageRoles: [], - }) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to read the secret request: ${errorMessage(error)}`, - ), - ), - ); - const item = projection.turnItems.find((candidate) => candidate.id === turnItemId); - if (item?.type === "secret_request" && item.secretStatus !== "pending") { - return item.secretStatus; - } - const current = projection.runs.find((candidate) => candidate.id === runId); - if ( - current === undefined || - ThreadManagementService.isTerminalRunStatus(current.status) - ) { - yield* record("cancelled"); - return "cancelled" as const; - } - yield* Effect.sleep(Duration.millis(SECRET_REQUEST_POLL_INTERVAL_MS)); - } - }).pipe( - Effect.timeoutOption( - Duration.millis( - Math.min(input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), - ), - ), - Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : closeCard)), - ); - if (Option.isNone(answered)) yield* closeCard; - const status = Option.isSome(answered) - ? answered.value - : yield* threadManagement - .getThreadRecords(threadId, ["turnItems"], { - turnItemTypes: ["secret_request"], - messageRoles: [], - }) - .pipe( - Effect.map((records) => { - const item = records.turnItems.find((candidate) => candidate.id === turnItemId); - return item?.type === "secret_request" && - (item.secretStatus === "saved" || item.secretStatus === "declined") - ? item.secretStatus - : ("timed_out" as const); - }), - Effect.orElseSucceed(() => "timed_out" as const), - ); - yield* Effect.annotateCurrentSpan({ "secret_request.status": status }); - yield* Metrics.increment(Metrics.secretRequestsTotal, { status }); - if (status !== "saved") return { status }; - const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId }); - if (Option.isNone(secretRef)) { - return yield* failure( - "orchestration_error", - "The user saved the secret, but it could not be read. Ask again with a new clientRequestId.", + capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope) { + const { parent, limits } = yield* loadCaller(scope); + const providers = yield* loadProviders; + const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); + const modelPreferences = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providerModelPreferences ?? {}), + Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), + ); + return { + parentThreadId: parent?.thread.id ?? null, + inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, + inheritedModel: parent?.thread.modelSelection.model ?? null, + runtimeMode: limits.runtimeMode, + interactionMode: limits.interactionMode, + providers: providers.map((provider) => { + const constraints = providerConstraints( + provider, + orchestrationCapableInstanceIds.has(provider.instanceId), ); - } - return { status, secretRef: secretRef.value }; - }).pipe(Effect.withSpan("OrchestratorMcpService.requestSecret")), - - capabilities: (scope) => - Effect.gen(function* () { - const { parent, limits } = yield* loadCaller(scope); - const providers = yield* loadProviders; - const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); - const modelPreferences = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providerModelPreferences ?? {}), - Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), + return { + providerInstanceId: provider.instanceId, + driverKind: provider.driver, + displayName: provider?.displayName ?? null, + models: + provider?.models + .filter( + (model) => + model.isCustom || + !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug), + ) + .map((model) => ({ + id: model.slug, + label: model.name ?? null, + ...(model.capabilities?.optionDescriptors === undefined + ? {} + : { options: model.capabilities.optionDescriptors }), + })) ?? [], + ...(provider.supportedRuntimeModes === undefined || + provider.supportedRuntimeModes.length === 0 + ? {} + : { runtimeModes: [...provider.supportedRuntimeModes] }), + canRunChildTask: constraints.length === 0, + canRunCrossProviderChildTask: constraints.length === 0, + constraints: [...constraints], + }; + }), + features: { + appOwnedSubagents: true, + asyncPolling: true, + cancellation: true, + batchThreadCreation: true, + threadManagement: true, + incrementalThreadRead: true, + scheduledTasks: true, + maxBatchThreads: 20, + }, + }; + }), + delegateTask: Effect.fn("OrchestratorMcpService.delegateTask")(function* (callerScope, input) { + const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task"); + const parentRun = parent.runs + .filter(ThreadManagementService.isActiveRun) + .toSorted((left, right) => right.ordinal - left.ordinal)[0]; + if ( + parentRun === undefined || + parentRun.rootNodeId === null || + parentRun.providerInstanceId !== scope.thread.providerInstanceId + ) { + return yield* failure( + "parent_not_active", + "Delegated tasks require an active run owned by this MCP provider session.", ); - return { - parentThreadId: parent?.thread.id ?? null, - inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, - inheritedModel: parent?.thread.modelSelection.model ?? null, - runtimeMode: limits.runtimeMode, - interactionMode: limits.interactionMode, - providers: providers.map((provider) => { - const constraints = providerConstraints( - provider, - orchestrationCapableInstanceIds.has(provider.instanceId), - ); - return { - providerInstanceId: provider.instanceId, - driverKind: provider.driver, - displayName: provider?.displayName ?? null, - models: - provider?.models - .filter( - (model) => - model.isCustom || - !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug), - ) - .map((model) => ({ - id: model.slug, - label: model.name ?? null, - ...(model.capabilities?.optionDescriptors === undefined - ? {} - : { options: model.capabilities.optionDescriptors }), - })) ?? [], - ...(provider.supportedRuntimeModes === undefined || - provider.supportedRuntimeModes.length === 0 - ? {} - : { runtimeModes: [...provider.supportedRuntimeModes] }), - canRunChildTask: constraints.length === 0, - canRunCrossProviderChildTask: constraints.length === 0, - constraints: [...constraints], - }; - }), - features: { - appOwnedSubagents: true, - asyncPolling: true, - cancellation: true, - batchThreadCreation: true, - threadManagement: true, - incrementalThreadRead: true, - scheduledTasks: true, - maxBatchThreads: 20, - }, - }; - }), - delegateTask: (callerScope, input) => - Effect.gen(function* () { - const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task"); - const parentRun = parent.runs - .filter(ThreadManagementService.isActiveRun) - .toSorted((left, right) => right.ordinal - left.ordinal)[0]; - if ( - parentRun === undefined || - parentRun.rootNodeId === null || - parentRun.providerInstanceId !== scope.thread.providerInstanceId - ) { - return yield* failure( - "parent_not_active", - "Delegated tasks require an active run owned by this MCP provider session.", + } + const providers = yield* loadProviders; + const key = yield* requestKey(input.clientRequestId); + const commandId = stableCommandId({ + scope, + requestKey: key, + operation: "delegate-task", + }); + const isRetryOfAcceptedDelegation = yield* threadManagement.hasReceipt(commandId); + const target = yield* resolveTargetRechecking({ + parent, + target: input.target, + providers, + skipProjectEnablementFilter: isRetryOfAcceptedDelegation, + }); + const runtimeMode = yield* resolveRuntimeMode( + parent.thread.runtimeMode, + input.runtimeMode, + target, + ); + const interactionMode = yield* resolveInteractionMode( + parent.thread.interactionMode, + input.interactionMode, + ); + let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy | undefined; + if (input.isolation === "worktree" && !isRetryOfAcceptedDelegation) { + const project = yield* requireProject(parent.thread.projectId); + const base = yield* gitDriver + .resolveCommit({ + cwd: parent.thread.worktreePath ?? project.workspaceRoot, + revision: "HEAD", + }) + .pipe( + Effect.mapError(() => + failure("invalid_request", "Unable to resolve the parent checkout commit."), + ), ); - } - const providers = yield* loadProviders; - const key = yield* requestKey(input.clientRequestId); - const commandId = stableCommandId({ - scope, - requestKey: key, - operation: "delegate-task", - }); - const isRetryOfAcceptedDelegation = yield* threadManagement.hasReceipt(commandId); - const target = yield* resolveTargetRechecking({ - parent, - target: input.target, - providers, - skipProjectEnablementFilter: isRetryOfAcceptedDelegation, - }); - const runtimeMode = yield* resolveRuntimeMode( - parent.thread.runtimeMode, - input.runtimeMode, - target, - ); - const interactionMode = yield* resolveInteractionMode( - parent.thread.interactionMode, - input.interactionMode, - ); - let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy | undefined; - if (input.isolation === "worktree" && !isRetryOfAcceptedDelegation) { - const project = yield* requireProject(parent.thread.projectId); - const base = yield* gitDriver - .resolveCommit({ - cwd: parent.thread.worktreePath ?? project.workspaceRoot, - revision: "HEAD", - }) + // Launch preparation creates the worktree from the project root, so a + // parent checkout in another repository (such as a Git folder inside + // Scratch) would hand it a commit it cannot find. + if (parent.thread.worktreePath !== null) { + yield* gitDriver + .resolveCommit({ cwd: project.workspaceRoot, revision: base.commitSha }) .pipe( Effect.mapError(() => - failure("invalid_request", "Unable to resolve the parent checkout commit."), + failure( + "invalid_request", + `Isolation "worktree" creates the child checkout in project "${project.title}" (${project.workspaceRoot}), which does not contain the parent checkout's commit. Use isolation "shared" to work in the parent's checkout.`, + ), ), ); - // Launch preparation creates the worktree from the project root, so a - // parent checkout in another repository (such as a Git folder inside - // Scratch) would hand it a commit it cannot find. - if (parent.thread.worktreePath !== null) { - yield* gitDriver - .resolveCommit({ cwd: project.workspaceRoot, revision: base.commitSha }) + } + workspaceStrategy = { type: "worktree", baseRef: base.commitSha, startFromOrigin: false }; + } + // ThreadManagementService.dispatch is the chokepoint for this command + // type's own enablement check, using the same command id and its + // receipt-replay exemption, so no separate assert runs here. + const result = yield* threadManagement + .dispatch({ + type: "delegated_task.request", + createdBy: "agent", + creationSource: "mcp", + commandId, + parentThreadId: scope.thread.threadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId, + task: taskPrompt(input), + ...(input.title === undefined ? {} : { title: input.title }), + modelSelection: target.modelSelection, + runtimeMode, + interactionMode, + ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), + // Async delegations wake the parent on every child terminal; wait + // delegations deliver through the blocking tool call, so a wake is + // only needed if the parent settled first (timeout, disconnect). + completionWake: input.mode === "wait" ? "settled_only" : "always", + }) + .pipe( + Effect.mapError((error) => + error._tag === "ThreadManagementProviderInstanceDisabledError" + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to create delegated task: ${errorMessage(error)}`, + ), + ), + ); + const taskEvent = result.storedEvents.find( + (stored) => + stored.event.type === "subagent.updated" && stored.event.payload.origin === "app_owned", + ); + if (taskEvent?.event.type !== "subagent.updated") { + return yield* failure( + "orchestration_error", + "Delegated task command did not produce a task projection.", + ); + } + const taskId = taskEvent.event.payload.id; + const childThreadId = taskEvent.event.payload.childThreadId; + const childRunEvent = result.storedEvents.find( + (stored) => stored.event.type === "run.created" && stored.event.threadId === childThreadId, + ); + if ( + childThreadId !== null && + childRunEvent?.event.type === "run.created" && + childRunEvent.event.payload.workspacePreparation !== undefined + ) { + yield* threadLaunch.prepareRun({ + commandId, + threadId: childThreadId, + runId: childRunEvent.event.payload.id, + }); + } + + if (input.mode !== "wait") { + return yield* readTask(scope, taskId, false, true); + } + const timeoutMs = Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ); + const waited = yield* waitForTask(scope, taskId, timeoutMs); + if (Option.isSome(waited) && isTerminalTaskStatus(waited.value.status)) { + return waited.value; + } + // An incomplete wait gives up delivery: upgrade so a later terminal + // wakes the parent even mid-turn. + yield* threadManagement + .dispatch({ + type: "delegated_task.wake-policy", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "delegate-task-wake-policy", + }), + parentThreadId: scope.thread.threadId, + taskId, + completionWake: "always", + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("orchestrator-mcp.delegate-task.wake-policy-failed", { + taskId, + outcome: + error._tag === "OrchestratorCommandPreviouslyRejectedError" + ? "previously_rejected" + : "dispatch_failed", + error, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("orchestrator-mcp.delegate-task.wake-policy-failed", { + taskId, + outcome: "defect", + cause, + }), + ), + ); + return yield* readTask(scope, taskId, Option.isNone(waited), true); + }), + taskStatus: Effect.fn("OrchestratorMcpService.taskStatus")(function* (callerScope, taskId) { + const scope = yield* requireThreadScope(callerScope, "task_status"); + return yield* readTask(scope, taskId, false, true); + }), + cancelTask: Effect.fn("OrchestratorMcpService.cancelTask")(function* (callerScope, input) { + const scope = yield* requireThreadScope(callerScope, "task_cancel"); + const current = yield* readTask(scope, input.taskId); + const key = yield* requestKey(input.clientRequestId); + const parentProjection = yield* loadProjection(scope.thread.threadId); + // Cancelling stops the child and every task under it, each a write to a + // thread its user may have raised above the parent's modes since it was + // delegated. All of them are checked before anything is stopped. + const assertStoppable = Effect.fn("OrchestratorMcpService.assertStoppable")(function* ( + threadId: ThreadId, + ): Effect.fn.Return { + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if (shell !== null && shell.deletedAt === null) { + yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode); + yield* resolveInteractionMode( + parentProjection.thread.interactionMode, + shell.interactionMode, + ); + } + const { subagents } = yield* threadManagement + .getThreadRecords(threadId, ["subagents"]) + .pipe(Effect.mapError(threadManagementFailure)); + for (const task of subagents) { + if (task.origin === "app_owned" && task.childThreadId !== null) { + yield* assertStoppable(task.childThreadId); + } + } + }); + yield* assertStoppable(current.childThreadId); + const parentTask = parentProjection.subagents.find( + (task) => task.id === input.taskId && task.origin === "app_owned", + ); + const disposeCompletionDelivery = + parentTask?.completionDelivery?.state === "disposed" + ? Effect.void + : threadManagement + .dispatch({ + type: "delegated_task.completion-delivery.dispose", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "cancel-task-completion-delivery", + }), + parentThreadId: scope.thread.threadId, + taskId: input.taskId, + }) .pipe( - Effect.mapError(() => + Effect.asVoid, + Effect.mapError((error) => failure( - "invalid_request", - `Isolation "worktree" creates the child checkout in project "${project.title}" (${project.workspaceRoot}), which does not contain the parent checkout's commit. Use isolation "shared" to work in the parent's checkout.`, + "orchestration_error", + `Unable to dispose delegated task ${input.taskId} completion delivery: ${errorMessage(error)}`, ), ), ); - } - workspaceStrategy = { type: "worktree", baseRef: base.commitSha, startFromOrigin: false }; - } - // ThreadManagementService.dispatch is the chokepoint for this command - // type's own enablement check, using the same command id and its - // receipt-replay exemption, so no separate assert runs here. - const result = yield* threadManagement + const stopChild = Effect.gen(function* () { + const commandId = stableCommandId({ scope, requestKey: key, operation: "cancel-task" }); + const reason = input.reason; + yield* threadManagement .dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", + type: "thread.stop", commandId, - parentThreadId: scope.thread.threadId, - parentRunId: parentRun.id, - parentNodeId: parentRun.rootNodeId, - task: taskPrompt(input), - ...(input.title === undefined ? {} : { title: input.title }), - modelSelection: target.modelSelection, - runtimeMode, - interactionMode, - ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), - // Async delegations wake the parent on every child terminal; wait - // delegations deliver through the blocking tool call, so a wake is - // only needed if the parent settled first (timeout, disconnect). - completionWake: input.mode === "wait" ? "settled_only" : "always", + threadId: current.childThreadId, + ...(reason === undefined ? {} : { reason }), }) .pipe( Effect.mapError((error) => - error._tag === "ThreadManagementProviderInstanceDisabledError" - ? threadManagementFailure(error) - : failure( - "orchestration_error", - `Unable to create delegated task: ${errorMessage(error)}`, - ), + failure( + "task_not_cancellable", + `Unable to stop delegated task ${input.taskId}: ${errorMessage(error)}`, + ), ), ); - const taskEvent = result.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && stored.event.payload.origin === "app_owned", - ); - if (taskEvent?.event.type !== "subagent.updated") { - return yield* failure( - "orchestration_error", - "Delegated task command did not produce a task projection.", - ); - } - const taskId = taskEvent.event.payload.id; - const childThreadId = taskEvent.event.payload.childThreadId; - const childRunEvent = result.storedEvents.find( - (stored) => - stored.event.type === "run.created" && stored.event.threadId === childThreadId, - ); - if ( - childThreadId !== null && - childRunEvent?.event.type === "run.created" && - childRunEvent.event.payload.workspacePreparation !== undefined - ) { - yield* threadLaunch.prepareRun({ - commandId, - threadId: childThreadId, - runId: childRunEvent.event.payload.id, - }); - } - - if (input.mode !== "wait") { - return yield* readTask(scope, taskId, false, true); - } - const timeoutMs = Math.min( - MAX_WAIT_TIMEOUT_MS, - Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), - ); - const waited = yield* waitForTask(scope, taskId, timeoutMs); - if (Option.isSome(waited) && isTerminalTaskStatus(waited.value.status)) { - return waited.value; - } - // An incomplete wait gives up delivery: upgrade so a later terminal - // wakes the parent even mid-turn. yield* threadManagement - .dispatch({ - type: "delegated_task.wake-policy", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "delegate-task-wake-policy", - }), - parentThreadId: scope.thread.threadId, - taskId, - completionWake: "always", - }) + .stopDelegatedTasks({ threadId: current.childThreadId, commandId, reason }) .pipe( - Effect.catch((error) => - Effect.logWarning("orchestrator-mcp.delegate-task.wake-policy-failed", { - taskId, - outcome: - error._tag === "OrchestratorCommandPreviouslyRejectedError" - ? "previously_rejected" - : "dispatch_failed", - error, - }), - ), - Effect.catchCause((cause) => - Effect.logWarning("orchestrator-mcp.delegate-task.wake-policy-failed", { - taskId, - outcome: "defect", - cause, - }), + Effect.mapError((error) => + failure( + "task_not_cancellable", + `Stopped delegated task ${input.taskId}, but not every task it delegated: ${errorMessage(error)}`, + ), ), ); - return yield* readTask(scope, taskId, Option.isNone(waited), true); - }), - taskStatus: (callerScope, taskId) => - Effect.gen(function* () { - const scope = yield* requireThreadScope(callerScope, "task_status"); - return yield* readTask(scope, taskId, false, true); - }), - cancelTask: (callerScope, input) => - Effect.gen(function* () { - const scope = yield* requireThreadScope(callerScope, "task_cancel"); - const current = yield* readTask(scope, input.taskId); - const key = yield* requestKey(input.clientRequestId); - const parentProjection = yield* loadProjection(scope.thread.threadId); - // Cancelling stops the child and every task under it, each a write to a - // thread its user may have raised above the parent's modes since it was - // delegated. All of them are checked before anything is stopped. - const assertStoppable = (threadId: ThreadId): Effect.Effect => - Effect.gen(function* () { - const shell = yield* threadManagement - .getThreadShell(threadId) - .pipe(Effect.mapError(threadManagementFailure)); - if (shell !== null && shell.deletedAt === null) { - yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode); - yield* resolveInteractionMode( - parentProjection.thread.interactionMode, - shell.interactionMode, - ); - } - const { subagents } = yield* threadManagement - .getThreadRecords(threadId, ["subagents"]) - .pipe(Effect.mapError(threadManagementFailure)); - for (const task of subagents) { - if (task.origin === "app_owned" && task.childThreadId !== null) { - yield* assertStoppable(task.childThreadId); - } - } - }); - yield* assertStoppable(current.childThreadId); - const parentTask = parentProjection.subagents.find( - (task) => task.id === input.taskId && task.origin === "app_owned", - ); - const disposeCompletionDelivery = - parentTask?.completionDelivery?.state === "disposed" - ? Effect.void - : threadManagement - .dispatch({ - type: "delegated_task.completion-delivery.dispose", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "cancel-task-completion-delivery", - }), - parentThreadId: scope.thread.threadId, - taskId: input.taskId, - }) - .pipe( - Effect.asVoid, - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to dispose delegated task ${input.taskId} completion delivery: ${errorMessage(error)}`, - ), - ), - ); - const stopChild = Effect.gen(function* () { - const commandId = stableCommandId({ scope, requestKey: key, operation: "cancel-task" }); - const reason = input.reason; + const stopped = yield* threadManagement + .getThreadRecords(current.childThreadId, ["runs"]) + .pipe(Effect.mapError(threadManagementFailure)); + for (const run of stopped.runs) { + if ( + run.status !== "queued" || + (run.delegatedTaskId !== input.taskId && run.id !== current.childRunId) + ) + continue; + // Stop preserves the queue for Resume. Cancelling a task must also + // remove that task's queued turn, including a previously held one. yield* threadManagement .dispatch({ - type: "thread.stop", - commandId, + type: "queued-run.cancel", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: `cancel-task-queued-run:${run.id}`, + }), threadId: current.childThreadId, - ...(reason === undefined ? {} : { reason }), + runId: run.id, }) - .pipe( - Effect.mapError((error) => - failure( - "task_not_cancellable", - `Unable to stop delegated task ${input.taskId}: ${errorMessage(error)}`, - ), - ), - ); - yield* threadManagement - .stopDelegatedTasks({ threadId: current.childThreadId, commandId, reason }) - .pipe( - Effect.mapError((error) => - failure( - "task_not_cancellable", - `Stopped delegated task ${input.taskId}, but not every task it delegated: ${errorMessage(error)}`, - ), - ), - ); - const stopped = yield* threadManagement - .getThreadRecords(current.childThreadId, ["runs"]) .pipe(Effect.mapError(threadManagementFailure)); - for (const run of stopped.runs) { - if ( - run.status !== "queued" || - (run.delegatedTaskId !== input.taskId && run.id !== current.childRunId) - ) - continue; - // Stop preserves the queue for Resume. Cancelling a task must also - // remove that task's queued turn, including a previously held one. - yield* threadManagement - .dispatch({ - type: "queued-run.cancel", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: `cancel-task-queued-run:${run.id}`, - }), - threadId: current.childThreadId, - runId: run.id, - }) - .pipe(Effect.mapError(threadManagementFailure)); - } - }); - // Checked above; a user raising one of these threads meanwhile is - // caught again under that thread's lock, which leaves it running. - const refused = yield* Ref.make(undefined); - const stopWithinLimit = stopChild.pipe( - Effect.provideService(DispatchModeLimit, { - runtimeMode: parentProjection.thread.runtimeMode, - interactionMode: parentProjection.thread.interactionMode, - refused, - }), - Effect.catch((error) => - Effect.flatMap(Ref.get(refused), (refusal) => - Effect.fail( - refusal === undefined - ? error - : failure( - refusal.mode === "runtime" - ? "runtime_mode_escalation_denied" - : "interaction_mode_escalation_denied", - `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this thread's; its user changed it while the task was being cancelled.`, - ), - ), + } + }); + // Checked above; a user raising one of these threads meanwhile is + // caught again under that thread's lock, which leaves it running. + const refused = yield* Ref.make(undefined); + const stopWithinLimit = stopChild.pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: parentProjection.thread.runtimeMode, + interactionMode: parentProjection.thread.interactionMode, + refused, + }), + Effect.catch((error) => + Effect.flatMap(Ref.get(refused), (refusal) => + Effect.fail( + refusal === undefined + ? error + : failure( + refusal.mode === "runtime" + ? "runtime_mode_escalation_denied" + : "interaction_mode_escalation_denied", + `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this thread's; its user changed it while the task was being cancelled.`, + ), ), ), - ); - if (isTerminalTaskStatus(current.status)) { - yield* stopWithinLimit; - yield* disposeCompletionDelivery; - return { - taskId: input.taskId, - status: current.status, - } satisfies OrchestratorMcpTaskCancelResult; - } - const child = yield* loadProjection(current.childThreadId); - if ( - current.workState !== "waiting_for_children" && - ThreadManagementService.latestActiveRun(child) === undefined && - !child.runs.some( - (run) => - run.status === "queued" && - (run.delegatedTaskId === input.taskId || run.id === current.childRunId), - ) - ) { - return yield* failure( - "task_not_cancellable", - `Delegated task ${input.taskId} has no interruptible child run.`, - ); - } + ), + ); + if (isTerminalTaskStatus(current.status)) { yield* stopWithinLimit; - yield* disposeCompletionDelivery.pipe( - Effect.catchCause((cause) => - Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", { - taskId: input.taskId, - cause, - }), - ), - ); + yield* disposeCompletionDelivery; return { taskId: input.taskId, - status: "cancel_requested", - }; - }), - createThreads: (callerScope, input) => - Effect.gen(function* () { + status: current.status, + } satisfies OrchestratorMcpTaskCancelResult; + } + const child = yield* loadProjection(current.childThreadId); + if ( + current.workState !== "waiting_for_children" && + ThreadManagementService.latestActiveRun(child) === undefined && + !child.runs.some( + (run) => + run.status === "queued" && + (run.delegatedTaskId === input.taskId || run.id === current.childRunId), + ) + ) { + return yield* failure( + "task_not_cancellable", + `Delegated task ${input.taskId} has no interruptible child run.`, + ); + } + yield* stopWithinLimit; + yield* disposeCompletionDelivery.pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", { + taskId: input.taskId, + cause, + }), + ), + ); + return { + taskId: input.taskId, + status: "cancel_requested", + }; + }), + createThreads: Effect.fn("OrchestratorMcpService.createThreads")( + function* (callerScope, input) { const { scope, parent } = yield* loadThreadCaller(callerScope, "create_threads"); const parentRun = ThreadManagementService.latestActiveRun(parent); if ( @@ -2490,263 +2486,253 @@ const make = Effect.gen(function* () { { concurrency: 1 }, ); return { threads: created }; - }), - listThreads: (scope, input) => - Effect.gen(function* () { - const { parent } = yield* loadCaller(scope); - const projectId = yield* resolveProjectTarget(parent, input.projectId); - const projectThreads = yield* threadManagement - .listProjectThreads({ - projectId, - includeSubagents: input.includeSubagents !== false, - }) - .pipe( - Effect.mapError((error) => - failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), - ), - ); - const nowMs = yield* Clock.currentTimeMillis; - const statuses = input.statuses === undefined ? null : new Set(input.statuses); - const titleContains = input.titleContains?.toLocaleLowerCase(); - const filtered = projectThreads - .filter( - (thread) => - statuses === null || statuses.has(thread.activityRunStatus ?? thread.status), - ) - .filter( - (thread) => - input.settled === undefined || threadSettlement(thread).settled === input.settled, - ) - .filter( - (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, - ) - .filter( - (thread) => - titleContains === undefined || - thread.title.toLocaleLowerCase().includes(titleContains), - ); - const cursor = input.cursor ?? 0; - const limit = input.limit ?? DEFAULT_THREAD_LIST_LIMIT; - const page = filtered.slice(cursor, cursor + limit); - const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null; - return { + }, + ), + listThreads: Effect.fn("OrchestratorMcpService.listThreads")(function* (scope, input) { + const { parent } = yield* loadCaller(scope); + const projectId = yield* resolveProjectTarget(parent, input.projectId); + const projectThreads = yield* threadManagement + .listProjectThreads({ projectId, - currentThreadId: parent?.thread.id ?? null, - threads: page.map((shell) => listItemFromShell(shell, nowMs)), - nextCursor, - total: filtered.length, - } satisfies OrchestratorMcpThreadListResult; - }), - readThread: (scope, input) => - Effect.gen(function* () { - const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId); - const view = input.view ?? "messages"; - const afterPosition = input.afterPosition ?? -1; - const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT; - const maxChars = input.maxCharsPerItem ?? DEFAULT_THREAD_ITEM_MAX_CHARS; - const timeline = yield* threadManagement - .getTimelinePage(input.threadId, { - afterPosition, - limit, - view, - ...(input.itemId === undefined ? {} : { itemId: input.itemId }), - }) - .pipe(Effect.mapError(threadManagementFailure)); - const page = timeline.items; - const messageIdsByThread = new Map>(); - for (const row of page) { - if (row.item.type !== "user_message" && row.item.type !== "assistant_message") continue; - const ids = messageIdsByThread.get(row.sourceThreadId) ?? []; - ids.push(row.item.messageId); - messageIdsByThread.set(row.sourceThreadId, ids); - } - const sourceMessages = yield* Effect.forEach( - [...messageIdsByThread], - ([threadId, messageIds]) => - threadManagement.getThreadRecords(threadId, ["messages"], { messageIds }).pipe( - Effect.map((records) => [threadId, records.messages] as const), - Effect.mapError(threadManagementFailure), - ), - { concurrency: 1 }, + includeSubagents: input.includeSubagents !== false, + }) + .pipe( + Effect.mapError((error) => + failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`), + ), ); - const messagesByThreadId = new Map(sourceMessages); - const returnedRunIds = new Set( - page - .filter((row) => row.sourceThreadId === target.thread.id) - .map((row) => row.item.runId), + const nowMs = yield* Clock.currentTimeMillis; + const statuses = input.statuses === undefined ? null : new Set(input.statuses); + const titleContains = input.titleContains?.toLocaleLowerCase(); + const filtered = projectThreads + .filter( + (thread) => statuses === null || statuses.has(thread.activityRunStatus ?? thread.status), + ) + .filter( + (thread) => + input.settled === undefined || threadSettlement(thread).settled === input.settled, + ) + .filter( + (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed, + ) + .filter( + (thread) => + titleContains === undefined || thread.title.toLocaleLowerCase().includes(titleContains), ); - const tasks = - parent === undefined ? [] : directAppOwnedChildTasks(parent, target, returnedRunIds); - if ( - parent !== undefined && - scope.thread !== undefined && - tasks.length > 0 && - (input.textOffset ?? 0) === 0 - ) { - for (const task of tasks) { - const transfer = taskResultTransfer(parent, target, task); - const resultRunId = transfer?.sourcePoint.runId ?? delegatedTaskRun(target, task)?.id; - const resultRunIds = resultRunId === undefined ? [] : [resultRunId]; - const resultRecords = yield* threadManagement - .getThreadRecords(target.thread.id, ["messages", "turnItems"], { - messageRoles: ["assistant"], - messageRunIds: resultRunIds, - turnItemRunIds: resultRunIds, - turnItemTypes: ["assistant_message", "error"], - }) - .pipe(Effect.mapError(threadManagementFailure)); - if ( - pageIncludesTerminalTaskResult({ - parent, - page, - task, - target: { ...target, ...resultRecords }, - maxChars, - }) - ) { - yield* readTask( - scope as McpThreadInvocationScope, - task.id, - false, - true, - "thread-read-acknowledge", - ); - } + const cursor = input.cursor ?? 0; + const limit = input.limit ?? DEFAULT_THREAD_LIST_LIMIT; + const page = filtered.slice(cursor, cursor + limit); + const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null; + return { + projectId, + currentThreadId: parent?.thread.id ?? null, + threads: page.map((shell) => listItemFromShell(shell, nowMs)), + nextCursor, + total: filtered.length, + } satisfies OrchestratorMcpThreadListResult; + }), + readThread: Effect.fn("OrchestratorMcpService.readThread")(function* (scope, input) { + const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId); + const view = input.view ?? "messages"; + const afterPosition = input.afterPosition ?? -1; + const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT; + const maxChars = input.maxCharsPerItem ?? DEFAULT_THREAD_ITEM_MAX_CHARS; + const timeline = yield* threadManagement + .getTimelinePage(input.threadId, { + afterPosition, + limit, + view, + ...(input.itemId === undefined ? {} : { itemId: input.itemId }), + }) + .pipe(Effect.mapError(threadManagementFailure)); + const page = timeline.items; + const messageIdsByThread = new Map>(); + for (const row of page) { + if (row.item.type !== "user_message" && row.item.type !== "assistant_message") continue; + const ids = messageIdsByThread.get(row.sourceThreadId) ?? []; + ids.push(row.item.messageId); + messageIdsByThread.set(row.sourceThreadId, ids); + } + const sourceMessages = yield* Effect.forEach( + [...messageIdsByThread], + ([threadId, messageIds]) => + threadManagement.getThreadRecords(threadId, ["messages"], { messageIds }).pipe( + Effect.map((records) => [threadId, records.messages] as const), + Effect.mapError(threadManagementFailure), + ), + { concurrency: 1 }, + ); + const messagesByThreadId = new Map(sourceMessages); + const returnedRunIds = new Set( + page.filter((row) => row.sourceThreadId === target.thread.id).map((row) => row.item.runId), + ); + const tasks = + parent === undefined ? [] : directAppOwnedChildTasks(parent, target, returnedRunIds); + if ( + parent !== undefined && + scope.thread !== undefined && + tasks.length > 0 && + (input.textOffset ?? 0) === 0 + ) { + for (const task of tasks) { + const transfer = taskResultTransfer(parent, target, task); + const resultRunId = transfer?.sourcePoint.runId ?? delegatedTaskRun(target, task)?.id; + const resultRunIds = resultRunId === undefined ? [] : [resultRunId]; + const resultRecords = yield* threadManagement + .getThreadRecords(target.thread.id, ["messages", "turnItems"], { + messageRoles: ["assistant"], + messageRunIds: resultRunIds, + turnItemRunIds: resultRunIds, + turnItemTypes: ["assistant_message", "error"], + }) + .pipe(Effect.mapError(threadManagementFailure)); + if ( + pageIncludesTerminalTaskResult({ + parent, + page, + task, + target: { ...target, ...resultRecords }, + maxChars, + }) + ) { + yield* readTask( + scope as McpThreadInvocationScope, + task.id, + false, + true, + "thread-read-acknowledge", + ); } } - return { - thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis), - recentRuns: target.runs - .toSorted((left, right) => right.ordinal - left.ordinal) - .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) - .map(threadRun), - items: page.map((row) => - timelineItem({ - row, - maxChars, - messagesByThreadId, - ...(input.itemId === undefined ? {} : { textOffset: input.textOffset ?? 0 }), - }), - ), - nextPosition: page.at(-1)?.position ?? null, - hasMore: timeline.hasMore, - } satisfies OrchestratorMcpThreadReadResult; - }), - sendToThread: (scope, input) => - Effect.gen(function* () { - const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); - yield* assertLiveCallerForOtherThread(scope, parent, target); - yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); - - const mode = input.mode ?? "auto"; - const key = yield* requestKey(input.clientRequestId); - const messageId = stableOperationMessageId({ - scope, - requestKey: key, - operation: "thread-send", - }); - const result = yield* threadManagement - .sendToThread({ - projectId: target.thread.projectId, - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "thread-send", - }), - threadId: input.threadId, - ...(parent === undefined ? {} : { senderThreadId: parent.thread.id }), - messageId, - text: input.message, - attachments: [], - mode, - createdBy: "agent", - creationSource: "mcp", - }) - .pipe( - Effect.mapError((error) => - isThreadManagementError(error) - ? threadManagementFailure(error) - : failure( - "orchestration_error", - `Unable to send to thread ${input.threadId}: ${userFacingDispatchErrorMessage(error) ?? errorMessage(error)}`, - ), - ), - ); - return { + } + return { + thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis), + recentRuns: target.runs + .toSorted((left, right) => right.ordinal - left.ordinal) + .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) + .map(threadRun), + items: page.map((row) => + timelineItem({ + row, + maxChars, + messagesByThreadId, + ...(input.itemId === undefined ? {} : { textOffset: input.textOffset ?? 0 }), + }), + ), + nextPosition: page.at(-1)?.position ?? null, + hasMore: timeline.hasMore, + } satisfies OrchestratorMcpThreadReadResult; + }), + sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) { + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + yield* assertLiveCallerForOtherThread(scope, parent, target); + yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); + + const mode = input.mode ?? "auto"; + const key = yield* requestKey(input.clientRequestId); + const messageId = stableOperationMessageId({ + scope, + requestKey: key, + operation: "thread-send", + }); + const result = yield* threadManagement + .sendToThread({ + projectId: target.thread.projectId, + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "thread-send", + }), threadId: input.threadId, + ...(parent === undefined ? {} : { senderThreadId: parent.thread.id }), messageId, - runId: result.run.id, - ...(result.run.delegatedTaskId === undefined - ? {} - : { taskId: result.run.delegatedTaskId }), - status: result.run.status, - delivery: result.delivery, - } satisfies OrchestratorMcpThreadSendResult; - }), - waitForThread: (scope, input) => - Effect.gen(function* () { - const { target } = yield* loadScopedThread(scope, input.threadId); - const result = yield* threadManagement - .waitForThread({ - projectId: target.thread.projectId, - threadId: input.threadId, - ...(input.runId === undefined ? {} : { runId: input.runId }), - timeoutMs: Math.min( - MAX_WAIT_TIMEOUT_MS, - Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), - ), - }) - .pipe(Effect.mapError(threadManagementFailure)); - return { + text: input.message, + attachments: [], + mode, + createdBy: "agent", + creationSource: "mcp", + }) + .pipe( + Effect.mapError((error) => + isThreadManagementError(error) + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to send to thread ${input.threadId}: ${userFacingDispatchErrorMessage(error) ?? errorMessage(error)}`, + ), + ), + ); + return { + threadId: input.threadId, + messageId, + runId: result.run.id, + ...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }), + status: result.run.status, + delivery: result.delivery, + } satisfies OrchestratorMcpThreadSendResult; + }), + waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { + const { target } = yield* loadScopedThread(scope, input.threadId); + const result = yield* threadManagement + .waitForThread({ + projectId: target.thread.projectId, threadId: input.threadId, - runId: result.run?.id ?? null, - status: result.run === null ? "idle" : runOutcome(result.run), - timedOut: result.timedOut, - } satisfies OrchestratorMcpThreadWaitResult; - }), - interruptThread: (scope, input) => - Effect.gen(function* () { - const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); - yield* assertLiveCallerForOtherThread(scope, parent, target); - yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); - const key = yield* requestKey(input.clientRequestId); - const result = yield* threadManagement - .interruptThread({ - projectId: target.thread.projectId, - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "thread-interrupt", - }), - threadId: input.threadId, - ...(input.runId === undefined ? {} : { runId: input.runId }), - ...(input.reason === undefined ? {} : { reason: input.reason }), - }) - .pipe( - Effect.mapError((error) => - isThreadManagementError(error) - ? threadManagementFailure(error) - : failure( - "orchestration_error", - `Unable to interrupt thread ${input.threadId}: ${errorMessage(error)}`, - ), - ), - ); - if (result.type === "no_active_run") { - return { - threadId: input.threadId, - runId: null, - status: "no_active_run", - } satisfies OrchestratorMcpThreadInterruptResult; - } + ...(input.runId === undefined ? {} : { runId: input.runId }), + timeoutMs: Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ), + }) + .pipe(Effect.mapError(threadManagementFailure)); + return { + threadId: input.threadId, + runId: result.run?.id ?? null, + status: result.run === null ? "idle" : runOutcome(result.run), + timedOut: result.timedOut, + } satisfies OrchestratorMcpThreadWaitResult; + }), + interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + yield* assertLiveCallerForOtherThread(scope, parent, target); + yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); + const key = yield* requestKey(input.clientRequestId); + const result = yield* threadManagement + .interruptThread({ + projectId: target.thread.projectId, + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "thread-interrupt", + }), + threadId: input.threadId, + ...(input.runId === undefined ? {} : { runId: input.runId }), + ...(input.reason === undefined ? {} : { reason: input.reason }), + }) + .pipe( + Effect.mapError((error) => + isThreadManagementError(error) + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to interrupt thread ${input.threadId}: ${errorMessage(error)}`, + ), + ), + ); + if (result.type === "no_active_run") { return { threadId: input.threadId, - runId: result.run.id, - status: result.type === "already_terminal" ? result.run.status : "interrupt_requested", + runId: null, + status: "no_active_run", } satisfies OrchestratorMcpThreadInterruptResult; - }), + } + return { + threadId: input.threadId, + runId: result.run.id, + status: result.type === "already_terminal" ? result.run.status : "interrupt_requested", + } satisfies OrchestratorMcpThreadInterruptResult; + }), }); }); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index d42efd9168c4..c4f3449a2906 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -69,46 +69,45 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev ), ); -const pickDevice = ( +const pickDevice = Effect.fn("handlers.pickDevice")(function* ( devices: ReadonlyArray, input: { readonly deviceId?: DeviceId | undefined; readonly platform?: DevicePlatform | undefined; readonly hostId?: DeviceHostId | undefined; }, -): Effect.Effect => - Effect.gen(function* () { - const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; - if (input.deviceId !== undefined) { - const match = devices.find( - (device) => device.hostId === hostId && device.id === input.deviceId, - ); - if (match) return match; - return yield* new DeviceToolUnavailableError({ - reason: `No device ${input.deviceId} on host ${hostId}. Call device_list for current ids.`, - }); - } - const candidates = devices.filter( - (device) => - device.hostId === hostId && - (input.platform === undefined || device.platform === input.platform), +): Effect.fn.Return { + const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; + if (input.deviceId !== undefined) { + const match = devices.find( + (device) => device.hostId === hostId && device.id === input.deviceId, ); - if (candidates.length === 0) { - return yield* new DeviceToolUnavailableError({ - reason: - input.platform === undefined - ? "No simulators or emulators were found. Call device_list to see why." - : `No ${input.platform} devices were found on host ${hostId}. Call device_list to see why.`, - }); - } - const platforms = new Set(candidates.map((device) => device.platform)); - if (input.platform === undefined && platforms.size > 1) { - return yield* new DeviceToolUnavailableError({ - reason: "Both iOS and Android devices are available; pass platform or deviceId.", - }); - } - return candidates.find((device) => device.booted) ?? candidates[0]!; - }); + if (match) return match; + return yield* new DeviceToolUnavailableError({ + reason: `No device ${input.deviceId} on host ${hostId}. Call device_list for current ids.`, + }); + } + const candidates = devices.filter( + (device) => + device.hostId === hostId && + (input.platform === undefined || device.platform === input.platform), + ); + if (candidates.length === 0) { + return yield* new DeviceToolUnavailableError({ + reason: + input.platform === undefined + ? "No simulators or emulators were found. Call device_list to see why." + : `No ${input.platform} devices were found on host ${hostId}. Call device_list to see why.`, + }); + } + const platforms = new Set(candidates.map((device) => device.platform)); + if (input.platform === undefined && platforms.size > 1) { + return yield* new DeviceToolUnavailableError({ + reason: "Both iOS and Android devices are available; pass platform or deviceId.", + }); + } + return candidates.find((device) => device.booted) ?? candidates[0]!; +}); const toolError = (error: DeviceError | DeviceToolUnavailableError) => error; diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 02ff69973975..0d1db856c1e6 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -130,65 +130,64 @@ const make = Effect.gen(function* () { Effect.forkScoped, ); - const endpoint = (key: DesktopTabKey) => - Effect.gen(function* () { - const id = keyOf(key); - const secret = NodeCrypto.randomBytes(24).toString("base64url"); - const server = yield* NodeSocketServer.makeWebSocket({ - host: "127.0.0.1", - port: 0, - path: `/${secret}`, - }).pipe(Effect.orDie); - const queue = yield* Queue.unbounded(); - inbound.set(id, queue); - // A detach before this registration shut down no queue, so check again. - if (!attachedTabs.has(id)) { - inbound.delete(id); - return yield* Effect.die("The desktop tab detached before the server connected."); - } - yield* Effect.addFinalizer(() => + const endpoint = Effect.fn("DesktopBrowserChannel.endpoint")(function* (key: DesktopTabKey) { + const id = keyOf(key); + const secret = NodeCrypto.randomBytes(24).toString("base64url"); + const server = yield* NodeSocketServer.makeWebSocket({ + host: "127.0.0.1", + port: 0, + path: `/${secret}`, + }).pipe(Effect.orDie); + const queue = yield* Queue.unbounded(); + inbound.set(id, queue); + // A detach before this registration shut down no queue, so check again. + if (!attachedTabs.has(id)) { + inbound.delete(id); + return yield* Effect.die("The desktop tab detached before the server connected."); + } + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (inbound.get(id) === queue) inbound.delete(id); + yield* Queue.shutdown(queue); + yield* command({ type: "release", ...key }); + }), + ); + // The relay serves one Playwright connection; a second would see the first's sessions. + let connected = false; + yield* server + .run((socket) => Effect.gen(function* () { - if (inbound.get(id) === queue) inbound.delete(id); - yield* Queue.shutdown(queue); - yield* command({ type: "release", ...key }); - }), - ); - // The relay serves one Playwright connection; a second would see the first's sessions. - let connected = false; - yield* server - .run((socket) => - Effect.gen(function* () { - if (connected) return; - connected = true; - const writer = yield* socket.writer; - const reader = yield* socket.reader; - yield* Stream.fromQueue(queue).pipe( - Stream.runForEach((message) => writer.write(message)), - Effect.forkScoped, - ); - const decoder = new TextDecoder(); - return yield* reader.pull.pipe( - Effect.flatMap((frames) => - Effect.forEach( - frames, - (frame) => - command({ - type: "cdp", - ...key, - message: typeof frame === "string" ? frame : decoder.decode(frame), - }), - { discard: true }, - ), + if (connected) return; + connected = true; + const writer = yield* socket.writer; + const reader = yield* socket.reader; + yield* Stream.fromQueue(queue).pipe( + Stream.runForEach((message) => writer.write(message)), + Effect.forkScoped, + ); + const decoder = new TextDecoder(); + return yield* reader.pull.pipe( + Effect.flatMap((frames) => + Effect.forEach( + frames, + (frame) => + command({ + type: "cdp", + ...key, + message: typeof frame === "string" ? frame : decoder.decode(frame), + }), + { discard: true }, ), - Effect.forever, - ); - }).pipe(Effect.scoped, Effect.ignore), - ) - .pipe(Effect.forkScoped); - const address = server.address; - if (address._tag !== "InetAddressV4") return yield* Effect.die("Unexpected relay address."); - return `ws://127.0.0.1:${address.port}/${secret}`; - }); + ), + Effect.forever, + ); + }).pipe(Effect.scoped, Effect.ignore), + ) + .pipe(Effect.forkScoped); + const address = server.address; + if (address._tag !== "InetAddressV4") return yield* Effect.die("Unexpected relay address."); + return `ws://127.0.0.1:${address.port}/${secret}`; + }); return DesktopBrowserChannel.of({ available: true, diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 03a9f76c4151..d9626784c32d 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1237,20 +1237,19 @@ const make = Effect.gen(function* () { return opening; }; - const findTab = (threadId: string, tabId: string) => - Effect.gen(function* () { - const existing = tabs.get(tabKey(threadId, tabId)); - if (existing) return existing; - const { sessions } = yield* manager.list({ threadId: ThreadId.make(threadId) }); - const snapshot = sessions.find( - (session) => session.tabId === tabId && session.runtime === "server", - ); - if (!snapshot) return yield* new ServerBrowserTabNotFoundError({ threadId, tabId }); - return yield* Effect.tryPromise({ - try: () => ensureTab(snapshot), - catch: (cause) => (isTabNotFound(cause) ? cause : new ServerBrowserLaunchError({ cause })), - }); + const findTab = Effect.fn("ServerBrowser.findTab")(function* (threadId: string, tabId: string) { + const existing = tabs.get(tabKey(threadId, tabId)); + if (existing) return existing; + const { sessions } = yield* manager.list({ threadId: ThreadId.make(threadId) }); + const snapshot = sessions.find( + (session) => session.tabId === tabId && session.runtime === "server", + ); + if (!snapshot) return yield* new ServerBrowserTabNotFoundError({ threadId, tabId }); + return yield* Effect.tryPromise({ + try: () => ensureTab(snapshot), + catch: (cause) => (isTabNotFound(cause) ? cause : new ServerBrowserLaunchError({ cause })), }); + }); const atTabLimit = (agentSessionId: string) => tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT || @@ -2220,209 +2219,209 @@ const make = Effect.gen(function* () { } }; - const attachViewer: ServerBrowser["Service"]["attachViewer"] = (input) => - Effect.gen(function* () { - const tab = yield* findTab(input.threadId, input.tabId); - const output = yield* Queue.make({ - capacity: VIEWER_OUTPUT_LIMIT, - strategy: "dropping", + const attachViewer: ServerBrowser["Service"]["attachViewer"] = Effect.fn( + "ServerBrowser.attachViewer", + )(function* (input) { + const tab = yield* findTab(input.threadId, input.tabId); + const output = yield* Queue.make({ + capacity: VIEWER_OUTPUT_LIMIT, + strategy: "dropping", + }); + const session = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => tab.page.context().newCDPSession(tab.page), + catch: (cause) => new ServerBrowserLaunchError({ cause }), + }), + (session) => + Effect.promise(() => + session + .send("Page.stopScreencast") + .catch(constVoid) + .then(() => session.detach()) + .catch(constVoid), + ), + ); + const quality = Math.min(100, Math.max(1, Math.round(input.quality))); + // Chromium may drop the final frame during a burst; send a still when it settles. + let framesInFlight = 0; + let mayHaveDropped = false; + let motion = false; + const recentFrames: Array = []; + let screencastParams = Promise.resolve(); + let screencastScale = 1; + const startScreencast = (scale: number) => { + screencastScale = scale; + screencastParams = screencastParams.then(async () => { + if (tab.capturing > 0) return; + await session.send("Page.stopScreencast").catch(constVoid); + await session + .send("Page.startScreencast", { + format: "jpeg", + quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, + maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), + maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), + }) + .catch(constVoid); }); - const session = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: () => tab.page.context().newCDPSession(tab.page), - catch: (cause) => new ServerBrowserLaunchError({ cause }), - }), - (session) => - Effect.promise(() => - session - .send("Page.stopScreencast") - .catch(constVoid) - .then(() => session.detach()) - .catch(constVoid), - ), - ); - const quality = Math.min(100, Math.max(1, Math.round(input.quality))); - // Chromium may drop the final frame during a burst; send a still when it settles. - let framesInFlight = 0; - let mayHaveDropped = false; - let motion = false; - const recentFrames: Array = []; - let screencastParams = Promise.resolve(); - let screencastScale = 1; - const startScreencast = (scale: number) => { - screencastScale = scale; - screencastParams = screencastParams.then(async () => { - if (tab.capturing > 0) return; - await session.send("Page.stopScreencast").catch(constVoid); - await session - .send("Page.startScreencast", { - format: "jpeg", - quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, - maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), - maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), - }) - .catch(constVoid); - }); + return screencastParams; + }; + const viewer: ViewerState = { + id: NodeCrypto.randomUUID(), + canOperate: input.canOperate, + pressedKeys: new Map(), + pressedButtons: new Map(), + push: (next) => { + // Dropped frames must still release Chromium. + if (Queue.offerUnsafe(output, next)) return; + if (next._tag === "frame") runFork(next.ack); + // State a stalled viewer cannot miss replaces its backlog. It runs + // synchronously so an older replacement can never land after a newer one. + else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { + const dropped = Effect.runSyncExit(Queue.clear(output)); + if (dropped._tag === "Failure") return; + Queue.offerUnsafe(output, next); + + const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; + if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); + } + }, + pause: () => { + screencastParams = screencastParams.then(() => + session.send("Page.stopScreencast").then(constVoid, constVoid), + ); return screencastParams; - }; - const viewer: ViewerState = { - id: NodeCrypto.randomUUID(), - canOperate: input.canOperate, - pressedKeys: new Map(), - pressedButtons: new Map(), - push: (next) => { - // Dropped frames must still release Chromium. - if (Queue.offerUnsafe(output, next)) return; - if (next._tag === "frame") runFork(next.ack); - // State a stalled viewer cannot miss replaces its backlog. It runs - // synchronously so an older replacement can never land after a newer one. - else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { - const dropped = Effect.runSyncExit(Queue.clear(output)); - if (dropped._tag === "Failure") return; - Queue.offerUnsafe(output, next); - - const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; - if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); - for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); - } - }, - pause: () => { - screencastParams = screencastParams.then(() => - session.send("Page.stopScreencast").then(constVoid, constVoid), - ); - return screencastParams; - }, - resume: () => startScreencast(screencastScale), - scrolledAt: 0, - requestedSize: null, - }; - yield* Effect.acquireRelease( - Effect.sync(() => { - tab.viewers.add(viewer); + }, + resume: () => startScreencast(screencastScale), + scrolledAt: 0, + requestedSize: null, + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + tab.viewers.add(viewer); + reportLiveTabs(); + }), + () => + Effect.promise(async () => { + await tab.control.disconnect(viewer.id, () => releaseViewerInput(viewer, session)); + tab.viewers.delete(viewer); + broadcastControl(tab); reportLiveTabs(); }), - () => - Effect.promise(async () => { - await tab.control.disconnect(viewer.id, () => releaseViewerInput(viewer, session)); - tab.viewers.delete(viewer); - broadcastControl(tab); - reportLiveTabs(); - }), - ); - if (input.canOperate && tab.control.agentId === null && tab.control.controller === null) { - yield* Effect.promise(() => tab.control.take(viewer.id)); - } - broadcastControl(tab); - pushFileChooser(tab); - // Full scale: a scaled capture would flash in every other viewer. - const pushStill = async () => { - const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(tab.page, session, { - format: "jpeg", - quality, - scale: 1, - }), - ).catch(() => null); - if (data) - viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); - }; - let settleTimer: ReturnType | null = null; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (settleTimer !== null) clearTimeout(settleTimer); + ); + if (input.canOperate && tab.control.agentId === null && tab.control.controller === null) { + yield* Effect.promise(() => tab.control.take(viewer.id)); + } + broadcastControl(tab); + pushFileChooser(tab); + // Full scale: a scaled capture would flash in every other viewer. + const pushStill = async () => { + const data = await withCaptureLock(tab, () => + ServerBrowserPage.captureViewport(tab.page, session, { + format: "jpeg", + quality, + scale: 1, }), - ); - let screencastStarted = false; - session.on("Page.screencastFrame", (frame) => { - screencastStarted = true; - if (framesInFlight > 0) mayHaveDropped = true; - framesInFlight += 1; - const arrivedAt = Date.now(); - recentFrames.push(arrivedAt); - while (recentFrames[0]! < arrivedAt - SCREENCAST_MOTION_WINDOW_MS) recentFrames.shift(); - if ( - !motion && - recentFrames.length >= SCREENCAST_MOTION_FRAMES && - arrivedAt - viewer.scrolledAt < SCREENCAST_MOTION_WINDOW_MS - ) { - motion = true; - void startScreencast(0.5); - } else if (motion && arrivedAt - viewer.scrolledAt > SCREENCAST_SETTLE_MS) { + ).catch(() => null); + if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); + }; + let settleTimer: ReturnType | null = null; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (settleTimer !== null) clearTimeout(settleTimer); + }), + ); + let screencastStarted = false; + session.on("Page.screencastFrame", (frame) => { + screencastStarted = true; + if (framesInFlight > 0) mayHaveDropped = true; + framesInFlight += 1; + const arrivedAt = Date.now(); + recentFrames.push(arrivedAt); + while (recentFrames[0]! < arrivedAt - SCREENCAST_MOTION_WINDOW_MS) recentFrames.shift(); + if ( + !motion && + recentFrames.length >= SCREENCAST_MOTION_FRAMES && + arrivedAt - viewer.scrolledAt < SCREENCAST_MOTION_WINDOW_MS + ) { + motion = true; + void startScreencast(0.5); + } else if (motion && arrivedAt - viewer.scrolledAt > SCREENCAST_SETTLE_MS) { + motion = false; + recentFrames.length = 0; + void startScreencast(1); + } + if (settleTimer !== null) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = null; + if (!motion && !mayHaveDropped) return; + mayHaveDropped = false; + if (motion) { motion = false; recentFrames.length = 0; void startScreencast(1); } - if (settleTimer !== null) clearTimeout(settleTimer); - settleTimer = setTimeout(() => { - settleTimer = null; - if (!motion && !mayHaveDropped) return; - mayHaveDropped = false; - if (motion) { - motion = false; - recentFrames.length = 0; - void startScreencast(1); - } - void pushStill(); - }, SCREENCAST_SETTLE_MS); - viewer.push({ - _tag: "frame", - data: Buffer.from(frame.data, "base64"), - ack: Effect.promise(() => - sleepUntil(arrivedAt + SCREENCAST_ACK_PACE_MS).then(() => { - framesInFlight -= 1; - return session - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(constVoid); - }), - ), - }); + void pushStill(); + }, SCREENCAST_SETTLE_MS); + viewer.push({ + _tag: "frame", + data: Buffer.from(frame.data, "base64"), + ack: Effect.promise(() => + sleepUntil(arrivedAt + SCREENCAST_ACK_PACE_MS).then(() => { + framesInFlight -= 1; + return session + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(constVoid); + }), + ), }); - broadcastViewport(tab); - yield* Effect.promise(() => startScreencast(1)); - // An idle page does not repaint for a new screencast, so the viewer - // starts from a still unless a live frame beat it. - if (!screencastStarted) yield* Effect.promise(pushStill); - return { - output, - input: (raw: unknown) => - Effect.promise(async () => { - if (!viewer.canOperate) return; - const message = asRecord(raw); - if (!message) return; - try { - if (message.type === "takeControl") { - const taking = tab.control.take(viewer.id); - broadcastControl(tab); - await taking; - pushFileChooser(tab); - } else if (message.type === "releaseControl") { - const releasing = tab.control.release(viewer.id, () => - releaseViewerInput(viewer, session), - ); - broadcastControl(tab); - await releasing; - } else if ( - message.type === "dialog" && - tab.control.controller === viewer.id && - typeof message.accept === "boolean" - ) { - await resolveDialog(tab, { - accept: message.accept, - ...(typeof message.promptText === "string" - ? { promptText: message.promptText } - : {}), - }); - } else { - await tab.control.human(viewer.id, () => - dispatchViewerInput(tab, session, viewer, message), - ); - } - } catch { + }); + broadcastViewport(tab); + yield* Effect.promise(() => startScreencast(1)); + // An idle page does not repaint for a new screencast, so the viewer + // starts from a still unless a live frame beat it. + if (!screencastStarted) yield* Effect.promise(pushStill); + return { + output, + input: (raw: unknown) => + Effect.promise(async () => { + if (!viewer.canOperate) return; + const message = asRecord(raw); + if (!message) return; + try { + if (message.type === "takeControl") { + const taking = tab.control.take(viewer.id); + broadcastControl(tab); + await taking; + pushFileChooser(tab); + } else if (message.type === "releaseControl") { + const releasing = tab.control.release(viewer.id, () => + releaseViewerInput(viewer, session), + ); broadcastControl(tab); + await releasing; + } else if ( + message.type === "dialog" && + tab.control.controller === viewer.id && + typeof message.accept === "boolean" + ) { + await resolveDialog(tab, { + accept: message.accept, + ...(typeof message.promptText === "string" + ? { promptText: message.promptText } + : {}), + }); + } else { + await tab.control.human(viewer.id, () => + dispatchViewerInput(tab, session, viewer, message), + ); } - }), - } satisfies ServerBrowserViewer; - }); + } catch { + broadcastControl(tab); + } + }), + } satisfies ServerBrowserViewer; + }); yield* manager.events.pipe(Stream.runForEach(mirrorManagerEvent), Effect.forkScoped); if ( diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 536d3f2c1920..ad42eaf5d729 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -51,164 +51,164 @@ const parseMessage = (chunk: Uint8Array | string): unknown => { } }; -const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); - if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/download`) { - return yield* serveDownload(browser, url.value.searchParams); - } - if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/upload`) { - return request.method === "POST" - ? yield* receiveUpload(browser, url.value.searchParams) - : HttpServerResponse.text("Method Not Allowed", { status: 405 }); - } - if ( - url.value.pathname !== `${PREVIEW_STREAM_ROUTE_PREFIX}/ws` || - request.headers.upgrade?.toLowerCase() !== "websocket" - ) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); - const params = url.value.searchParams; - const canOperate = - session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false"; - const threadId = params.get("threadId") ?? ""; - const tabId = params.get("tabId") ?? ""; - if (threadId.length === 0 || tabId.length === 0) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - return yield* Effect.scoped( - Effect.gen(function* () { - const attached = yield* browser - .attachViewer({ - threadId, - tabId, - canOperate, - maxWidth: intParam(params, "maxWidth", 1280, 7680), - maxHeight: intParam(params, "maxHeight", 800, 4320), - quality: intParam(params, "quality", DEFAULT_QUALITY, 100), - }) - .pipe( - Effect.map((viewer) => ({ _tag: "attached" as const, viewer })), - Effect.catchTags({ - ServerBrowserTabNotFoundError: () => Effect.succeed({ _tag: "gone" as const }), - ServerBrowserLaunchError: (error) => { - const setup = hostSetup(error.cause); - return setup === undefined - ? Effect.fail(error) - : Effect.succeed({ _tag: "hostSetup" as const, reason: encodeHostSetup(setup) }); - }, - }), - ); - const incoming = NodeHttpServerRequest.toIncomingMessage(request); - // JPEGs are already compressed. Disabling deflate also keeps all - // pending writes in the socket buffer we bound below, not a zlib queue. - delete incoming.headers["sec-websocket-extensions"]; - const transport = incoming.socket; - const socket = yield* request.upgrade; - const reader = yield* socket.reader; - const writer = yield* socket.writer; - // A refused upgrade reads as an auth failure to ticket clients, so a - // missing tab is a close code they stop on. - const gone = writer.write(new Socket.CloseEvent(TAB_GONE_CODE, "tab closed")); - if (attached._tag === "gone") { - yield* gone; - return HttpServerResponse.empty(); - } - if (attached._tag === "hostSetup") { - yield* writer.write( - new Socket.CloseEvent(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, attached.reason), - ); - return HttpServerResponse.empty(); - } - const viewer = attached.viewer; - // `write` returns once the frame is queued, so Chromium's ack for each - // frame waits for the viewer's `ack` message instead. Only one frame - // is on the wire and only the newest waits behind it, so replies never - // queue behind stale images on a slow link. - let inFlight: Effect.Effect | null = null; - let pending: Frame | null = null; - const disconnectSlowViewer = Effect.sync(() => transport.destroy()).pipe( - Effect.andThen(Effect.interrupt), +// Request middleware owns tracing, including long-lived WebSocket sessions. +const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBrowser["Service"]) { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/download`) { + return yield* serveDownload(browser, url.value.searchParams); + } + if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/upload`) { + return request.method === "POST" + ? yield* receiveUpload(browser, url.value.searchParams) + : HttpServerResponse.text("Method Not Allowed", { status: 405 }); + } + if ( + url.value.pathname !== `${PREVIEW_STREAM_ROUTE_PREFIX}/ws` || + request.headers.upgrade?.toLowerCase() !== "websocket" + ) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); + const params = url.value.searchParams; + const canOperate = + session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false"; + const threadId = params.get("threadId") ?? ""; + const tabId = params.get("tabId") ?? ""; + if (threadId.length === 0 || tabId.length === 0) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + return yield* Effect.scoped( + Effect.gen(function* () { + const attached = yield* browser + .attachViewer({ + threadId, + tabId, + canOperate, + maxWidth: intParam(params, "maxWidth", 1280, 7680), + maxHeight: intParam(params, "maxHeight", 800, 4320), + quality: intParam(params, "quality", DEFAULT_QUALITY, 100), + }) + .pipe( + Effect.map((viewer) => ({ _tag: "attached" as const, viewer })), + Effect.catchTags({ + ServerBrowserTabNotFoundError: () => Effect.succeed({ _tag: "gone" as const }), + ServerBrowserLaunchError: (error) => { + const setup = hostSetup(error.cause); + return setup === undefined + ? Effect.fail(error) + : Effect.succeed({ _tag: "hostSetup" as const, reason: encodeHostSetup(setup) }); + }, + }), + ); + const incoming = NodeHttpServerRequest.toIncomingMessage(request); + // JPEGs are already compressed. Disabling deflate also keeps all + // pending writes in the socket buffer we bound below, not a zlib queue. + delete incoming.headers["sec-websocket-extensions"]; + const transport = incoming.socket; + const socket = yield* request.upgrade; + const reader = yield* socket.reader; + const writer = yield* socket.writer; + // A refused upgrade reads as an auth failure to ticket clients, so a + // missing tab is a close code they stop on. + const gone = writer.write(new Socket.CloseEvent(TAB_GONE_CODE, "tab closed")); + if (attached._tag === "gone") { + yield* gone; + return HttpServerResponse.empty(); + } + if (attached._tag === "hostSetup") { + yield* writer.write( + new Socket.CloseEvent(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, attached.reason), ); - const write = (data: Uint8Array | string) => - Effect.suspend(() => { - const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; - if (transport.writableLength + bytes + 14 > MAX_SOCKET_BUFFER_BYTES) { - return disconnectSlowViewer; + return HttpServerResponse.empty(); + } + const viewer = attached.viewer; + // `write` returns once the frame is queued, so Chromium's ack for each + // frame waits for the viewer's `ack` message instead. Only one frame + // is on the wire and only the newest waits behind it, so replies never + // queue behind stale images on a slow link. + let inFlight: Effect.Effect | null = null; + let pending: Frame | null = null; + const disconnectSlowViewer = Effect.sync(() => transport.destroy()).pipe( + Effect.andThen(Effect.interrupt), + ); + const write = (data: Uint8Array | string) => + Effect.suspend(() => { + const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; + if (transport.writableLength + bytes + 14 > MAX_SOCKET_BUFFER_BYTES) { + return disconnectSlowViewer; + } + return writer.write(data); + }); + const sendFrame = (frame: Frame) => { + inFlight = frame.ack; + return write(frame.data); + }; + const sendOutput = Queue.take(viewer.output).pipe( + Effect.flatMap((output) => { + switch (output._tag) { + case "frame": { + if (inFlight === null) return sendFrame(output); + const superseded = pending; + pending = output; + // Never sent, but Chromium still waits for its ack. + return superseded ? Effect.forkScoped(superseded.ack) : Effect.void; } - return writer.write(data); - }); - const sendFrame = (frame: Frame) => { - inFlight = frame.ack; - return write(frame.data); - }; - const sendOutput = Queue.take(viewer.output).pipe( - Effect.flatMap((output) => { - switch (output._tag) { - case "frame": { - if (inFlight === null) return sendFrame(output); - const superseded = pending; - pending = output; - // Never sent, but Chromium still waits for its ack. - return superseded ? Effect.forkScoped(superseded.ack) : Effect.void; - } - case "viewport": - case "control": - case "clipboard": - case "download": - case "fileChooser": - case "fileChooserClosed": - case "popup": - case "pointer": - case "probe": { - const { _tag: type, ...data } = output; - return write(JSON.stringify({ type, ...data })); - } - case "gone": - return gone.pipe(Effect.andThen(Effect.interrupt)); - case "reconnect": - return writer - .write(new Socket.CloseEvent(1012, "reconnect")) - .pipe(Effect.andThen(Effect.interrupt)); + case "viewport": + case "control": + case "clipboard": + case "download": + case "fileChooser": + case "fileChooserClosed": + case "popup": + case "pointer": + case "probe": { + const { _tag: type, ...data } = output; + return write(JSON.stringify({ type, ...data })); } - }), - ); - const receive = (chunk: Uint8Array | string) => { - const message = parseMessage(chunk); - // Ownership serializes actions in the service. Keep reading so dialog - // replies and takeover can unblock an action already waiting on the page. - if (!isAck(message)) - return canOperate - ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid) - : Effect.void; - const ack = inFlight; - const next = pending; - inFlight = null; - pending = null; - // Forked: Chromium acks are paced and must not hold up input. - return Effect.andThen( - ack ? Effect.forkScoped(ack) : Effect.void, - next ? sendFrame(next) : Effect.void, - ); - }; - const receiveInput = reader.pull.pipe( - Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), + case "gone": + return gone.pipe(Effect.andThen(Effect.interrupt)); + case "reconnect": + return writer + .write(new Socket.CloseEvent(1012, "reconnect")) + .pipe(Effect.andThen(Effect.interrupt)); + } + }), + ); + const receive = (chunk: Uint8Array | string) => { + const message = parseMessage(chunk); + // Ownership serializes actions in the service. Keep reading so dialog + // replies and takeover can unblock an action already waiting on the page. + if (!isAck(message)) + return canOperate + ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid) + : Effect.void; + const ack = inFlight; + const next = pending; + inFlight = null; + pending = null; + // Forked: Chromium acks are paced and must not hold up input. + return Effect.andThen( + ack ? Effect.forkScoped(ack) : Effect.void, + next ? sendFrame(next) : Effect.void, ); - return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); - }), - ).pipe( - Effect.catchTags({ - ServerBrowserLaunchError: (error) => - Effect.logWarning("server preview browser failed to start", { cause: error.cause }).pipe( - Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })), - ), - }), - Effect.catch(() => Effect.succeed(HttpServerResponse.empty())), - ); - }); + }; + const receiveInput = reader.pull.pipe( + Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), + ); + return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput)); + }), + ).pipe( + Effect.catchTags({ + ServerBrowserLaunchError: (error) => + Effect.logWarning("server preview browser failed to start", { cause: error.cause }).pipe( + Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })), + ), + }), + Effect.catch(() => Effect.succeed(HttpServerResponse.empty())), + ); +}); const hostSetup = (cause: unknown): PreviewStreamHostSetup | undefined => isSandboxError(cause) @@ -220,28 +220,32 @@ const isSandboxError = Schema.is(PreviewBrowserHost.PreviewBrowserSandboxError); const isLibrariesError = Schema.is(PreviewBrowserHost.PreviewBrowserLibrariesError); const encodeHostSetup = Schema.encodeSync(Schema.fromJsonString(PreviewStreamHostSetup)); -const serveDownload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => - Effect.gen(function* () { - yield* authenticateMediaRequest(AuthOrchestrationReadScope); - const download = yield* browser.openDownload({ - threadId: params.get("threadId") ?? "", - tabId: params.get("tabId") ?? "", - downloadId: params.get("id") ?? "", - }); - if (Option.isNone(download)) return HttpServerResponse.text("Not Found", { status: 404 }); - return yield* HttpServerResponse.file(download.value.path, { - headers: assetResponseHeaders(download.value.path, { - download: true, - fileName: download.value.fileName, - }), - }); +// The request span already covers this download route. +const serveDownload = Effect.fnUntraced(function* ( + browser: ServerBrowser.ServerBrowser["Service"], + params: URLSearchParams, +) { + yield* authenticateMediaRequest(AuthOrchestrationReadScope); + const download = yield* browser.openDownload({ + threadId: params.get("threadId") ?? "", + tabId: params.get("tabId") ?? "", + downloadId: params.get("id") ?? "", + }); + if (Option.isNone(download)) return HttpServerResponse.text("Not Found", { status: 404 }); + return yield* HttpServerResponse.file(download.value.path, { + headers: assetResponseHeaders(download.value.path, { + download: true, + fileName: download.value.fileName, + }), }); +}); const UPLOAD_MAX_FILE_BYTES = 100 * 1024 * 1024; const UPLOAD_MAX_FILES = 20; -const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => - Effect.gen(function* () { +// The request span already covers this upload route. +const receiveUpload = Effect.fnUntraced( + function* (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) { yield* authenticateMediaRequest(AuthPreviewOperateScope); const request = yield* HttpServerRequest.HttpServerRequest; const fs = yield* FileSystem.FileSystem; @@ -276,7 +280,9 @@ const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: return answered ? HttpServerResponse.empty({ status: 204 }) : HttpServerResponse.text("The page's file picker is no longer open.", { status: 409 }); - }).pipe(Effect.scoped); + }, + (effect, _browser, _params) => effect.pipe(Effect.scoped), +); export const routeLayer = HttpRouter.use((router) => Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c8e012301293..75b86b84f6bc 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -109,37 +109,39 @@ export const makeCommandGate = Effect.gen(function* () { ); yield* Effect.forkScoped(commandWorker); + const enqueueCommand = Effect.fn("serverRuntimeStartup.enqueueCommand")(function* ( + effect: Effect.Effect, + ): Effect.fn.Return { + const readinessState = yield* Ref.get(commandReadinessState); + if (readinessState === "ready") { + return yield* effect; + } + if (readinessState !== "pending") { + return yield* readinessState; + } + + const result = yield* Deferred.make(); + yield* Queue.offer(commandQueue, { + run: Deferred.await(commandReady).pipe( + Effect.flatMap(() => effect), + Effect.exit, + Effect.flatMap((exit) => settleQueuedCommand(result, exit)), + ), + }); + return yield* Deferred.await(result); + }); + return { awaitCommandReady: Deferred.await(commandReady), signalCommandReady: Effect.gen(function* () { yield* Ref.set(commandReadinessState, "ready"); yield* Deferred.succeed(commandReady, undefined).pipe(Effect.orDie); }), - failCommandReady: (error) => - Effect.gen(function* () { - yield* Ref.set(commandReadinessState, error); - yield* Deferred.fail(commandReady, error).pipe(Effect.orDie); - }), - enqueueCommand: (effect: Effect.Effect) => - Effect.gen(function* () { - const readinessState = yield* Ref.get(commandReadinessState); - if (readinessState === "ready") { - return yield* effect; - } - if (readinessState !== "pending") { - return yield* readinessState; - } - - const result = yield* Deferred.make(); - yield* Queue.offer(commandQueue, { - run: Deferred.await(commandReady).pipe( - Effect.flatMap(() => effect), - Effect.exit, - Effect.flatMap((exit) => settleQueuedCommand(result, exit)), - ), - }); - return yield* Deferred.await(result); - }), + failCommandReady: Effect.fn("serverRuntimeStartup.failCommandReady")(function* (error) { + yield* Ref.set(commandReadinessState, error); + yield* Deferred.fail(commandReady, error).pipe(Effect.orDie); + }), + enqueueCommand, } satisfies CommandGate; }); @@ -358,21 +360,22 @@ export const logBrowserAutoOpenUnavailable = Effect.logInfo("browser auto-open u hint: "Open the pairing URL above in your browser.", }); -export const maybeOpenBrowser = (target: string) => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - if (serverConfig.noBrowser) { - return; - } - const externalLauncher = yield* ExternalLauncher.ExternalLauncher; +export const maybeOpenBrowser = Effect.fn("serverRuntimeStartup.maybeOpenBrowser")(function* ( + target: string, +) { + const serverConfig = yield* ServerConfig.ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const externalLauncher = yield* ExternalLauncher.ExternalLauncher; - yield* externalLauncher.launchBrowser(target).pipe( - // The target carries the pairing token, and a failed launch records its - // command line on the span, so this call stays out of traces. - Effect.withTracerEnabled(false), - Effect.catch(() => logBrowserAutoOpenUnavailable), - ); - }); + yield* externalLauncher.launchBrowser(target).pipe( + // The target carries the pairing token, and a failed launch records its + // command line on the span, so this call stays out of traces. + Effect.withTracerEnabled(false), + Effect.catch(() => logBrowserAutoOpenUnavailable), + ); +}); const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( @@ -413,7 +416,9 @@ export const startEffectWorkerWithRelay = Effect.fn( ); }); -export function runOrderedV2StartupPhases< +export const runOrderedV2StartupPhases = Effect.fn( + "serverRuntimeStartup.runOrderedV2StartupPhases", +)(function* < Import, Recovery, Bootstrap, @@ -434,292 +439,286 @@ export function runOrderedV2StartupPhases< readonly startEffectWorker: Effect.Effect; readonly autoBootstrap: Effect.Effect; }) { - return Effect.gen(function* () { - yield* input.importLegacyShells; - const recovery = yield* input.recover; - yield* input.recoverDelegatedTasks; - yield* input.startEffectWorker; - const bootstrap = yield* input.autoBootstrap; - return { recovery, bootstrap } as const; - }); -} + yield* input.importLegacyShells; + const recovery = yield* input.recover; + yield* input.recoverDelegatedTasks; + yield* input.startEffectWorker; + const bootstrap = yield* input.autoBootstrap; + return { recovery, bootstrap } as const; +}); -const make = (options?: StartupOptions) => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const keybindings = yield* Keybindings.Keybindings; - const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; - const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; - const orchestrator = yield* Orchestrator.OrchestratorV2; - const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; - const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; - const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const crypto = yield* Crypto.Crypto; - const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; - - const commandGate = yield* makeCommandGate; - const httpListening = yield* Deferred.make(); - const effectWorkerFiber = yield* Ref.make | null>(null); - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* commandGate.failCommandReady( - new ServerRuntimeStartupError({ - mode: serverConfig.mode, - host: serverConfig.host ?? null, - port: serverConfig.port, - cause: "Server runtime is shutting down.", - }), - ); - const workerFiber = yield* Ref.getAndSet(effectWorkerFiber, null); - if (workerFiber !== null) { - yield* Fiber.interrupt(workerFiber).pipe(Effect.ignore); - } - yield* providerRuntimeRecovery.prepareForShutdown.pipe( - Effect.ensuring(providerSessions.shutdown), - ); - const reconciliation = yield* providerRuntimeRecovery.reconcile("shutdown"); - yield* Effect.logInfo("V2 orchestration shutdown reconciliation completed", reconciliation); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("V2 orchestration shutdown reconciliation failed", { - cause: Cause.pretty(cause), +const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: StartupOptions) { + const serverConfig = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const crypto = yield* Crypto.Crypto; + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; + + const commandGate = yield* makeCommandGate; + const httpListening = yield* Deferred.make(); + const effectWorkerFiber = yield* Ref.make | null>(null); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* commandGate.failCommandReady( + new ServerRuntimeStartupError({ + mode: serverConfig.mode, + host: serverConfig.host ?? null, + port: serverConfig.port, + cause: "Server runtime is shutting down.", + }), + ); + const workerFiber = yield* Ref.getAndSet(effectWorkerFiber, null); + if (workerFiber !== null) { + yield* Fiber.interrupt(workerFiber).pipe(Effect.ignore); + } + yield* providerRuntimeRecovery.prepareForShutdown.pipe( + Effect.ensuring(providerSessions.shutdown), + ); + const reconciliation = yield* providerRuntimeRecovery.reconcile("shutdown"); + yield* Effect.logInfo("V2 orchestration shutdown reconciliation completed", reconciliation); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("V2 orchestration shutdown reconciliation failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const startup = Effect.gen(function* () { + yield* Effect.logDebug("startup phase: starting keybindings runtime"); + yield* runStartupPhase( + "keybindings.start", + keybindings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + detail: error.detail, + cause: error.cause, }), ), ), ); - const startup = Effect.gen(function* () { - yield* Effect.logDebug("startup phase: starting keybindings runtime"); - yield* runStartupPhase( - "keybindings.start", - keybindings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start keybindings runtime", { - path: error.configPath, - detail: error.detail, - cause: error.cause, - }), - ), + yield* Effect.logDebug("startup phase: starting server settings runtime"); + yield* runStartupPhase( + "settings.start", + serverSettings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start server settings runtime", { + path: error.settingsPath, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }), ), - ); + ), + ); - yield* Effect.logDebug("startup phase: starting server settings runtime"); - yield* runStartupPhase( - "settings.start", - serverSettings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start server settings runtime", { - path: error.settingsPath, - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }), + const welcomeBase = yield* resolveWelcomeBase; + const environment = yield* serverEnvironment.getDescriptor; + const legacyMigrationThreadCount = yield* legacyV1ThreadImporter.pendingThreadCount; + if (legacyMigrationThreadCount > 0) { + yield* lifecycleEvents.publish({ + version: 1, + type: "legacyThreadMigration", + payload: { + status: "running", + totalThreadCount: legacyMigrationThreadCount, + }, + }); + } + const { recovery, bootstrap: bootstrapTargets } = yield* runOrderedV2StartupPhases({ + importLegacyShells: runStartupPhase( + "orchestration-v2.legacy-v1.import-shells", + legacyV1ThreadImporter.reconcileShells.pipe( + Effect.tap((summary) => + summary.importedThreadCount === 0 + ? Effect.void + : Effect.logInfo("Imported legacy v1 thread shells", summary), ), ), - ); + ), + recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), + recoverDelegatedTasks: runStartupPhase( + "orchestration-v2.delegated-tasks.recover", + orchestrator.recoverDelegatedTasks, + ), + startEffectWorker: runStartupPhase( + "orchestration-v2.effect-worker.start", + startEffectWorkerWithRelay({ + runWorker: EffectWorker.runDaemon, + startRelay: agentAwarenessRelay.start(), + workerFiberRef: effectWorkerFiber, + }), + ), + autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd + ? runStartupPhase( + "welcome.autobootstrap", + resolveAutoBootstrapWelcomeTargets.pipe(Effect.provideService(Crypto.Crypto, crypto)), + ) + : Effect.succeed({}) + ).pipe(Effect.map((targets): AutoBootstrapWelcomeTargets => targets)), + }); + yield* Effect.logInfo("V2 orchestration recovery completed", recovery); + // Runs after activation: the status check fetches every enabled project's + // remote, and awaiting it here held command readiness for that long. + yield* runStartupPhase( + "projects.auto-pull", + Effect.gen(function* () { + const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); + const settings = yield* serverSettings.getSettings; + yield* autoPullProjects(projects, settings); + }), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to load projects for automatic pull", { cause }), + ), + forkParked, + ); - const welcomeBase = yield* resolveWelcomeBase; - const environment = yield* serverEnvironment.getDescriptor; - const legacyMigrationThreadCount = yield* legacyV1ThreadImporter.pendingThreadCount; - if (legacyMigrationThreadCount > 0) { - yield* lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "running", - totalThreadCount: legacyMigrationThreadCount, - }, - }); - } - const { recovery, bootstrap: bootstrapTargets } = yield* runOrderedV2StartupPhases({ - importLegacyShells: runStartupPhase( - "orchestration-v2.legacy-v1.import-shells", - legacyV1ThreadImporter.reconcileShells.pipe( - Effect.tap((summary) => - summary.importedThreadCount === 0 - ? Effect.void - : Effect.logInfo("Imported legacy v1 thread shells", summary), + const importPendingTranscripts = legacyV1ThreadImporter.importPendingTranscripts.pipe( + Effect.tap((summary) => + summary.importedThreadCount === 0 + ? Effect.void + : Effect.logInfo("Hydrated legacy v1 thread transcripts", summary), + ), + ); + yield* ( + legacyMigrationThreadCount > 0 + ? importPendingTranscripts.pipe( + Effect.tap(() => + lifecycleEvents.publish({ + version: 1, + type: "legacyThreadMigration", + payload: { + status: "complete", + totalThreadCount: legacyMigrationThreadCount, + }, + }), ), - ), - ), - recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), - recoverDelegatedTasks: runStartupPhase( - "orchestration-v2.delegated-tasks.recover", - orchestrator.recoverDelegatedTasks, - ), - startEffectWorker: runStartupPhase( - "orchestration-v2.effect-worker.start", - startEffectWorkerWithRelay({ - runWorker: EffectWorker.runDaemon, - startRelay: agentAwarenessRelay.start(), - workerFiberRef: effectWorkerFiber, - }), - ), - autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd - ? runStartupPhase( - "welcome.autobootstrap", - resolveAutoBootstrapWelcomeTargets.pipe(Effect.provideService(Crypto.Crypto, crypto)), - ) - : Effect.succeed({}) - ).pipe(Effect.map((targets): AutoBootstrapWelcomeTargets => targets)), - }); - yield* Effect.logInfo("V2 orchestration recovery completed", recovery); - // Runs after activation: the status check fetches every enabled project's - // remote, and awaiting it here held command readiness for that long. - yield* runStartupPhase( - "projects.auto-pull", - Effect.gen(function* () { - const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); - const settings = yield* serverSettings.getSettings; - yield* autoPullProjects(projects, settings); - }), - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to load projects for automatic pull", { cause }), - ), - forkParked, - ); + ) + : importPendingTranscripts + ).pipe(forkParked); - const importPendingTranscripts = legacyV1ThreadImporter.importPendingTranscripts.pipe( - Effect.tap((summary) => - summary.importedThreadCount === 0 - ? Effect.void - : Effect.logInfo("Hydrated legacy v1 thread transcripts", summary), - ), - ); - yield* ( - legacyMigrationThreadCount > 0 - ? importPendingTranscripts.pipe( - Effect.tap(() => - lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "complete", - totalThreadCount: legacyMigrationThreadCount, - }, - }), - ), - ) - : importPendingTranscripts - ).pipe(forkParked); - - yield* forkParked( - Effect.gen(function* () { - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* recordStartupHeartbeat.pipe( - Effect.withSpan("server.startup.heartbeat.record", { - attributes: { "startup.phase": "heartbeat.record" }, - }), - Effect.ignoreCause({ log: true }), + yield* forkParked( + Effect.gen(function* () { + yield* Effect.logDebug("startup phase: recording startup heartbeat"); + yield* recordStartupHeartbeat.pipe( + Effect.withSpan("server.startup.heartbeat.record", { + attributes: { "startup.phase": "heartbeat.record" }, + }), + Effect.ignoreCause({ log: true }), + ); + if (serverConfig.startupPresentation === "headless") { + yield* Effect.logDebug("startup phase: headless access info"); + const accessInfo = yield* issueHeadlessServeAccessInfo(); + yield* runStartupPhase( + "headless.output", + Console.log(formatHeadlessServeOutput(accessInfo)), ); - if (serverConfig.startupPresentation === "headless") { - yield* Effect.logDebug("startup phase: headless access info"); - const accessInfo = yield* issueHeadlessServeAccessInfo(); - yield* runStartupPhase( - "headless.output", - Console.log(formatHeadlessServeOutput(accessInfo)), - ); - } else { - yield* Effect.logDebug("startup phase: browser open check"); - const startupBrowserTarget = yield* resolveStartupBrowserTarget; - if (serverConfig.mode !== "desktop") { - yield* announceStartupPairingUrl(startupBrowserTarget); - } - yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + } else { + yield* Effect.logDebug("startup phase: browser open check"); + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* announceStartupPairingUrl(startupBrowserTarget); } - }), - ); - - yield* Effect.logDebug("startup phase: waiting for http listener"); - yield* runStartupPhase("http.wait", Deferred.await(httpListening)); - yield* runStartupPhase( - "auxiliary-roots.parked", - options?.awaitAuxiliaryParked ?? Effect.void, - ); + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + } + }), + ); - const updateOutcome = yield* launcher.prepareTrial; + yield* Effect.logDebug("startup phase: waiting for http listener"); + yield* runStartupPhase("http.wait", Deferred.await(httpListening)); + yield* runStartupPhase("auxiliary-roots.parked", options?.awaitAuxiliaryParked ?? Effect.void); - yield* Effect.logDebug("startup phase: publishing welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); - yield* runStartupPhase( - "welcome.publish", - lifecycleEvents.publish({ - version: 1, - type: "welcome", - payload: { - environment, - ...welcomeBase, - ...bootstrapTargets, - }, - }), - ); + const updateOutcome = yield* launcher.prepareTrial; - yield* options?.activate ?? Effect.void; - yield* Effect.logDebug("Accepting commands"); - yield* commandGate.signalCommandReady; - yield* Effect.logDebug("startup phase: publishing ready event"); - yield* runStartupPhase( - "ready.publish", - lifecycleEvents.publish({ - version: 1, - type: "ready", - payload: { - at: DateTime.formatIso(yield* DateTime.now), - environment, - ...(updateOutcome === undefined ? {} : { updateOutcome }), - }, - }), - ); - yield* Effect.logDebug("startup phase: complete"); - yield* flushCompileCache; - }).pipe( - Effect.withSpan("server.startup", { - kind: "server", - root: true, - attributes: { - "server.mode": serverConfig.mode, - "server.port": serverConfig.port, - "server.host": serverConfig.host ?? "default", + yield* Effect.logDebug("startup phase: publishing welcome event", { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + bootstrapProjectId: bootstrapTargets.bootstrapProjectId, + bootstrapThreadId: bootstrapTargets.bootstrapThreadId, + }); + yield* runStartupPhase( + "welcome.publish", + lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapTargets, }, }), ); - yield* Effect.forkScoped( - Effect.exit(startup).pipe( - Effect.flatMap((startupExit) => { - if (Exit.isSuccess(startupExit)) return Effect.void; - const error = new ServerRuntimeStartupError({ - mode: serverConfig.mode, - host: serverConfig.host ?? null, - port: serverConfig.port, - cause: startupExit.cause, - }); - return Effect.logError("server runtime startup failed", { - cause: Cause.pretty(startupExit.cause), - }).pipe( - Effect.andThen(commandGate.failCommandReady(error)), - Effect.andThen(options?.abort?.(error) ?? Effect.void), - ); - }), - ), + yield* options?.activate ?? Effect.void; + yield* Effect.logDebug("Accepting commands"); + yield* commandGate.signalCommandReady; + yield* Effect.logDebug("startup phase: publishing ready event"); + yield* runStartupPhase( + "ready.publish", + lifecycleEvents.publish({ + version: 1, + type: "ready", + payload: { + at: DateTime.formatIso(yield* DateTime.now), + environment, + ...(updateOutcome === undefined ? {} : { updateOutcome }), + }, + }), ); + yield* Effect.logDebug("startup phase: complete"); + yield* flushCompileCache; + }).pipe( + Effect.withSpan("server.startup", { + kind: "server", + root: true, + attributes: { + "server.mode": serverConfig.mode, + "server.port": serverConfig.port, + "server.host": serverConfig.host ?? "default", + }, + }), + ); - return { - awaitCommandReady: commandGate.awaitCommandReady, - markHttpListening: Deferred.succeed(httpListening, undefined), - enqueueCommand: commandGate.enqueueCommand, - } satisfies ServerRuntimeStartup["Service"]; - }); + yield* Effect.forkScoped( + Effect.exit(startup).pipe( + Effect.flatMap((startupExit) => { + if (Exit.isSuccess(startupExit)) return Effect.void; + const error = new ServerRuntimeStartupError({ + mode: serverConfig.mode, + host: serverConfig.host ?? null, + port: serverConfig.port, + cause: startupExit.cause, + }); + return Effect.logError("server runtime startup failed", { + cause: Cause.pretty(startupExit.cause), + }).pipe( + Effect.andThen(commandGate.failCommandReady(error)), + Effect.andThen(options?.abort?.(error) ?? Effect.void), + ); + }), + ), + ); + + return { + awaitCommandReady: commandGate.awaitCommandReady, + markHttpListening: Deferred.succeed(httpListening, undefined), + enqueueCommand: commandGate.enqueueCommand, + } satisfies ServerRuntimeStartup["Service"]; +}); export const layerWithOptions = (options?: StartupOptions) => Layer.effect(ServerRuntimeStartup, make(options)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 976f0229de3f..e19a47e1fe34 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -279,24 +279,23 @@ type EditorDiscovery = Pick< "resolveAvailableEditors" | "resolveFileManagerRevealKind" >; -const resolveEditorConfig = ( +const resolveEditorConfig = Effect.fn("ws.resolveEditorConfig")(function* ( availableEditors: ReadonlyArray, revealKind: Effect.Effect, -) => - Effect.gen(function* () { - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* revealKind - : undefined; - return { - availableEditors, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), - }; - }); +) { + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* revealKind + : undefined; + return { + availableEditors, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + }; +}); export const withLateEditorConfig = ( config: ClientServerConfig, @@ -1642,64 +1641,63 @@ const layerWsRpc = ( ), ); - const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand - ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) - : currentProviders; - const settings = ServerSettings.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ); - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); - const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { + readonly usageLimitsCommand: boolean; + }) { + const keybindingsConfig = yield* keybindings.loadConfigState; + const currentProviders = yield* providerRegistry.getProviders; + const providers = options.usageLimitsCommand + ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) + : currentProviders; + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; + const editorConfig = yield* resolveEditorConfig( + yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), + resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), + ); - return { - environment, - auth, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - ...editorConfig, - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined - ? { otlpTracesUrl: config.otlpTracesUrl } - : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined - ? { otlpMetricsUrl: config.otlpMetricsUrl } - : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}), - otlpLogsEnabled: config.otlpLogsUrl !== undefined, - }, - settings, - shellResumeCompletionMarker: true, - threadResumeCompletionMarker: true, - threadSnapshotPagination: true, - threadFind: true, - threadFindProgressive: true, - ...Option.match(scratchWorkspaceRoot, { - onNone: () => ({}), - onSome: (root) => ({ scratchWorkspaceRoot: root }), - }), - newProjectsRoot: managedFolders.namedProjectsRoot, - }; - }); + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + ...editorConfig, + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), + directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}), + otlpLogsEnabled: config.otlpLogsUrl !== undefined, + }, + settings, + shellResumeCompletionMarker: true, + threadResumeCompletionMarker: true, + threadSnapshotPagination: true, + threadFind: true, + threadFindProgressive: true, + ...Option.match(scratchWorkspaceRoot, { + onNone: () => ({}), + onSome: (root) => ({ scratchWorkspaceRoot: root }), + }), + newProjectsRoot: managedFolders.namedProjectsRoot, + }; + }); const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster @@ -2150,8 +2148,9 @@ const layerWsRpc = ( }), ), ), - [WS_METHODS.serverRefreshProviders]: (input) => - Effect.gen(function* () { + [WS_METHODS.serverRefreshProviders]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (input) { if (input.refreshModels) { yield* modelManifest.forceRefresh; const instances = yield* providerInstances.listInstances; @@ -2225,59 +2224,65 @@ const layerWsRpc = ( [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), [WS_METHODS.mcpAppsUpdateModelContext]: (input) => mcpAppRequests.updateModelContext(input), [WS_METHODS.mcpAppsReadResource]: (input) => mcpAppRequests.readResource(input), - [WS_METHODS.providerUploadFeedback]: (input) => - Effect.gen(function* () { - const projection = yield* threadManagement.getThreadRecords(input.threadId, [ - "providerThreads", - ]); - const providerThread = - projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ) ?? projection.providerThreads.at(-1); - const providerSessionId = providerThread?.providerSessionId ?? null; - if (providerThread === undefined || providerSessionId === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "No provider session has run in this thread yet.", - }), - ); - } - const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); - if (runtime === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "The provider session is no longer running. Send a message first.", - }), - ); - } - if (runtime.uploadFeedback === undefined) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: `Provider '${runtime.driver}' does not support feedback uploads.`, - }), - ); - } - return yield* runtime.uploadFeedback({ - providerThread, - ...(input.reason === undefined ? {} : { reason: input.reason }), - }); - }).pipe( - Effect.mapError((cause) => - isProviderUploadFeedbackError(cause) - ? cause - : new ProviderUploadFeedbackError({ + [WS_METHODS.providerUploadFeedback]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced( + function* (input) { + const projection = yield* threadManagement.getThreadRecords(input.threadId, [ + "providerThreads", + ]); + const providerThread = + projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ) ?? projection.providerThreads.at(-1); + const providerSessionId = providerThread?.providerSessionId ?? null; + if (providerThread === undefined || providerSessionId === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ threadId: input.threadId, - cause, + cause: "No provider session has run in this thread yet.", }), - ), + ); + } + const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); + if (runtime === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "The provider session is no longer running. Send a message first.", + }), + ); + } + if (runtime.uploadFeedback === undefined) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: `Provider '${runtime.driver}' does not support feedback uploads.`, + }), + ); + } + return yield* runtime.uploadFeedback({ + providerThread, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isProviderUploadFeedbackError(cause) + ? cause + : new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause, + }), + ), + ), ), [WS_METHODS.serverUpdateProvider]: (input) => providerMaintenanceRunner.updateProvider(input), - [WS_METHODS.providerConsumeResetCredit]: (input) => - Effect.gen(function* () { + [WS_METHODS.providerConsumeResetCredit]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (input) { if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input); const instance = yield* providerInstances.getInstance(input.instanceId); if (instance === undefined || !instance.enabled) { @@ -2332,13 +2337,15 @@ const layerWsRpc = ( ServerSelfUpdate.updateProgressStream(serverSelfUpdate, input), [WS_METHODS.serverCommitDesktopUpdate]: (input) => serverSelfUpdate.commitDesktopUpdate(input.requestId), - [WS_METHODS.serverUpsertKeybinding]: (rule) => - Effect.gen(function* () { + [WS_METHODS.serverUpsertKeybinding]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (rule) { const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), - [WS_METHODS.serverRemoveKeybinding]: (rule) => - Effect.gen(function* () { + [WS_METHODS.serverRemoveKeybinding]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (rule) { const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), @@ -2347,8 +2354,9 @@ const layerWsRpc = ( [WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status, [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), - [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => - Effect.gen(function* () { + [WS_METHODS.serverUpdateSettings]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* ({ patch, providerInstanceMutation }) { const deviceHosts = patch.deviceHosts ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( Effect.provide(deviceHostContext), @@ -2659,8 +2667,9 @@ const layerWsRpc = ( [WS_METHODS.agentSessionsScan]: () => agentSessionScanner.scan, [WS_METHODS.agentSessionsImport]: (input) => agentSessionImporter.importRecentAgentThreads(input), - [WS_METHODS.assetsCreateUrl]: (input) => - Effect.gen(function* () { + [WS_METHODS.assetsCreateUrl]: + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (input) { const path = yield* Path.Path; if ( input.resource._tag === "attachment" || From b12631291728c175c720f0cd660f3218cf2af236 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:31:01 -0700 Subject: [PATCH 010/458] refactor(effect): trace relay and development tooling operations (#21997) --- .../record-codex-app-server-replay-fixture.ts | 261 +++---- infra/relay/src/hooks/HookForwarder.test.ts | 23 + infra/relay/src/hooks/HookForwarder.ts | 82 +- infra/relay/src/hooks/HookInboxObject.ts | 34 +- infra/relay/src/hooks/HookInboxStore.test.ts | 30 + infra/relay/src/hooks/HookInboxStore.ts | 65 +- infra/relay/src/hooks/upstream.ts | 80 +- infra/relay/src/http/Api.ts | 77 +- scripts/build-cli-archive.ts | 15 +- scripts/build-desktop-artifact.ts | 194 ++--- scripts/dev-runner.ts | 736 +++++++++--------- scripts/mobile-native-client.ts | 59 +- scripts/mobile-native-static-check.ts | 70 +- scripts/mock-update-server.ts | 79 +- scripts/resolve-previous-release-tag.ts | 53 +- 15 files changed, 957 insertions(+), 901 deletions(-) diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index b66e25ae9e7d..f22876571676 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -1110,7 +1110,7 @@ function recorderThreadConfigMetadata(scenario: ReplayScenario) { return keys.length === 0 ? {} : { recorderThreadConfigKeys: keys }; } -function makeRecorder({ +const makeRecorder = Effect.fn("record-codex-app-server-replay-fixture.makeRecorder")(function* ({ outPath, scenario, checkout, @@ -1118,55 +1118,53 @@ function makeRecorder({ readonly outPath: string; readonly scenario: ReplayScenario; readonly checkout: string; -}): Effect.Effect { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - let version = "unknown"; - const records: Array> = []; - const setVersion = (nextVersion: string) => - Effect.sync(() => { - version = nextVersion; - }); - const writeRecord = (record: Record) => - Effect.sync(() => { - records.push(record); - }); - const flush = () => { - const outputRecords = codexReplayRecordingOutputRecords(records, { - workspace: process.cwd(), - machine: { - home: NodeOS.homedir(), - hostname: NodeOS.hostname(), - checkout, - }, - }); - return fs.writeFileString( - outPath, - `${[ - { - type: "transcript_start", - provider: "codex", - protocol: "codex.app-server", - version, - scenario: scenario.name, - metadata: { - source: "record-codex-app-server-replay-fixture", - fileName: scenario.fileName, - description: scenario.description, - model: scenarioModel(scenario), - ...recorderThreadConfigMetadata(scenario), - }, +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + let version = "unknown"; + const records: Array> = []; + const setVersion = (nextVersion: string) => + Effect.sync(() => { + version = nextVersion; + }); + const writeRecord = (record: Record) => + Effect.sync(() => { + records.push(record); + }); + const flush = () => { + const outputRecords = codexReplayRecordingOutputRecords(records, { + workspace: process.cwd(), + machine: { + home: NodeOS.homedir(), + hostname: NodeOS.hostname(), + checkout, + }, + }); + return fs.writeFileString( + outPath, + `${[ + { + type: "transcript_start", + provider: "codex", + protocol: "codex.app-server", + version, + scenario: scenario.name, + metadata: { + source: "record-codex-app-server-replay-fixture", + fileName: scenario.fileName, + description: scenario.description, + model: scenarioModel(scenario), + ...recorderThreadConfigMetadata(scenario), }, - ...outputRecords, - ] - .map((record) => JSON.stringify(record)) - .join("\n")}\n`, - ); - }; + }, + ...outputRecords, + ] + .map((record) => JSON.stringify(record)) + .join("\n")}\n`, + ); + }; - return { path: outPath, setVersion, writeRecord, flush }; - }); -} + return { path: outPath, setVersion, writeRecord, flush }; +}); function layerCodex({ recorder }: { readonly recorder: Recorder }) { const clientRequestMethodById = new Map(); @@ -1348,16 +1346,16 @@ function installReplayHandlers({ ); } -function runReplaySession({ - scenario, - run, - recorder, -}: { - readonly scenario: ReplayScenario; - readonly run: ReplayRun; - readonly recorder: Recorder; -}) { - return Effect.gen(function* () { +const runReplaySession = Effect.fn("record-codex-app-server-replay-fixture.runReplaySession")( + function* ({ + scenario, + run, + recorder, + }: { + readonly scenario: ReplayScenario; + readonly run: ReplayRun; + readonly recorder: Recorder; + }) { const startedTurns = yield* makeReplayRecorderDeferredRegistry(); const completedTurns = yield* makeReplayRecorderDeferredRegistry(); const startedCommandExecutions = yield* makeReplayRecorderDeferredRegistry(); @@ -1397,73 +1395,72 @@ function runReplaySession({ return client; }); - const runTurnStep = ( + const runTurnStep = Effect.fn("record-codex-app-server-replay-fixture.runTurnStep")(function* ( client: CodexClient.CodexAppServerClient["Service"], threadId: string, step: TurnReplayStep, - ) => - Effect.gen(function* () { - const turnParams: TurnStartParams = { - approvalPolicy: "never", - sandboxPolicy: { type: "dangerFullAccess" }, - cwd: process.cwd(), - model, - summary: "detailed", - approvalsReviewer: "user", - ...(run.interactionMode === "plan" - ? { collaborationMode: planCollaborationMode(model) } - : {}), - ...run.turnDefaults, - ...step.turnOverrides, - input: turnInput(step.prompt), - threadId, - }; - - if (step.type === "steeredTurn") { - approvalGate = yield* Deferred.make(); - } + ) { + const turnParams: TurnStartParams = { + approvalPolicy: "never", + sandboxPolicy: { type: "dangerFullAccess" }, + cwd: process.cwd(), + model, + summary: "detailed", + approvalsReviewer: "user", + ...(run.interactionMode === "plan" + ? { collaborationMode: planCollaborationMode(model) } + : {}), + ...run.turnDefaults, + ...step.turnOverrides, + input: turnInput(step.prompt), + threadId, + }; + + if (step.type === "steeredTurn") { + approvalGate = yield* Deferred.make(); + } - const turn = yield* client.request("turn/start", turnParams); - const turnId = getTurnId(turn); - const started = yield* getStarted(turnId); - yield* getCompletion(turnId); + const turn = yield* client.request("turn/start", turnParams); + const turnId = getTurnId(turn); + const started = yield* getStarted(turnId); + yield* getCompletion(turnId); - if (step.type === "steeredTurn") { - yield* Deferred.await(started); - yield* client.request("turn/steer", { - expectedTurnId: turnId, - input: turnInput(step.steer), - threadId, - }); - if (approvalGate) { - yield* Deferred.succeed(approvalGate, void 0); - approvalGate = undefined; - } + if (step.type === "steeredTurn") { + yield* Deferred.await(started); + yield* client.request("turn/steer", { + expectedTurnId: turnId, + input: turnInput(step.steer), + threadId, + }); + if (approvalGate) { + yield* Deferred.succeed(approvalGate, void 0); + approvalGate = undefined; } + } - if (step.type === "interruptedTurn") { - if (step.interruptAfterCommandExecutionStarted === true) { - const commandExecutionStarted = yield* getCommandExecutionStarted(turnId); - yield* Deferred.await(commandExecutionStarted); - } else { - yield* Effect.sleep(`${step.interruptAfterMs ?? 1_500} millis`); - } - yield* client.request("turn/interrupt", { + if (step.type === "interruptedTurn") { + if (step.interruptAfterCommandExecutionStarted === true) { + const commandExecutionStarted = yield* getCommandExecutionStarted(turnId); + yield* Deferred.await(commandExecutionStarted); + } else { + yield* Effect.sleep(`${step.interruptAfterMs ?? 1_500} millis`); + } + yield* client.request("turn/interrupt", { + threadId, + turnId, + }); + for (const processId of runningCommandProcessIds.get(turnId)?.values() ?? []) { + yield* client.raw.request("thread/backgroundTerminals/terminate", { threadId, - turnId, + processId, }); - for (const processId of runningCommandProcessIds.get(turnId)?.values() ?? []) { - yield* client.raw.request("thread/backgroundTerminals/terminate", { - threadId, - processId, - }); - } } + } - const completed = yield* getCompletion(turnId); - yield* Deferred.await(completed); - lastTurnIdByThread.set(threadId, turnId); - }); + const completed = yield* getCompletion(turnId); + yield* Deferred.await(completed); + lastTurnIdByThread.set(threadId, turnId); + }); if (scenario.name === "provider_thread_resume") { const firstStep = run.steps[0]; @@ -1611,36 +1608,34 @@ function runReplaySession({ }), ), ); - }); -} + }, +); -function runScenario({ +const runScenario = Effect.fn("record-codex-app-server-replay-fixture.runScenario")(function* ({ scenario, outPath, }: { readonly scenario: ReplayScenario; readonly outPath: string; }) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const checkout = path.resolve(yield* path.fromFileUrl(new URL("../../..", import.meta.url))); - const recorder = yield* makeRecorder({ outPath, scenario, checkout }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const checkout = path.resolve(yield* path.fromFileUrl(new URL("../../..", import.meta.url))); + const recorder = yield* makeRecorder({ outPath, scenario, checkout }); - yield* fs.makeDirectory(path.dirname(outPath), { recursive: true }); - yield* Console.log(`Writing ${scenario.name} Codex replay events to ${recorder.path}`); + yield* fs.makeDirectory(path.dirname(outPath), { recursive: true }); + yield* Console.log(`Writing ${scenario.name} Codex replay events to ${recorder.path}`); - yield* Effect.forEach(scenario.runs, (run) => runReplaySession({ scenario, run, recorder }), { - concurrency: 1, - }); + yield* Effect.forEach(scenario.runs, (run) => runReplaySession({ scenario, run, recorder }), { + concurrency: 1, + }); - yield* recorder.writeRecord({ - type: "runtime_exit", - status: "success", - }); - yield* recorder.flush(); + yield* recorder.writeRecord({ + type: "runtime_exit", + status: "success", }); -} + yield* recorder.flush(); +}); const program = Effect.gen(function* () { const path = yield* Path.Path; diff --git a/infra/relay/src/hooks/HookForwarder.test.ts b/infra/relay/src/hooks/HookForwarder.test.ts index 0da5bc219bbf..c243127830f8 100644 --- a/infra/relay/src/hooks/HookForwarder.test.ts +++ b/infra/relay/src/hooks/HookForwarder.test.ts @@ -700,6 +700,29 @@ describe("HookForwarder", () => { .find((span) => span.name === "relay.hooks.forward") ?.attributes.has("relay.hook.upstream_outcome"), ).toBe(false); + + for (const [holdWebhooksWhileOffline, inboxCapacity, outcome] of [ + [false, Infinity, "environment_unavailable"], + [true, Infinity, "held"], + [true, 0, "inbox_full"], + ] as const) { + spans.length = 0; + yield* forward( + makeHarness({ + links: [{ ...managedLink, holdWebhooksWhileOffline }], + inboxCapacity, + execute: (request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("", { status: 503 })), + ), + }), + ); + expect( + spans + .find((span) => span.name === "relay.hooks.forward") + ?.attributes.get("relay.hook.outcome"), + ).toBe(outcome); + } }), ); diff --git a/infra/relay/src/hooks/HookForwarder.ts b/infra/relay/src/hooks/HookForwarder.ts index 65bed83d758b..2c13063cffdc 100644 --- a/infra/relay/src/hooks/HookForwarder.ts +++ b/infra/relay/src/hooks/HookForwarder.ts @@ -236,14 +236,14 @@ const make = Effect.gen(function* () { const inbox = yield* HookInbox.HookInbox; const crypto = yield* Crypto.Crypto; - const signDeliveryProof = (input: { - readonly environmentId: string; - readonly deliveryId: string; - readonly receivedAt: string; - readonly hookId: string; - readonly jti: string; - }) => - Effect.gen(function* () { + const signDeliveryProof = Effect.fn("HookForwarder.signDeliveryProof")( + function* (input: { + readonly environmentId: string; + readonly deliveryId: string; + readonly receivedAt: string; + readonly hookId: string; + readonly jti: string; + }) { const now = Math.floor((yield* Clock.currentTimeMillis) / 1_000); return yield* signRelayJwt({ privateKey: Redacted.value(settings.cloudMintPrivateKey), @@ -261,7 +261,9 @@ const make = Effect.gen(function* () { hookId: input.hookId, } satisfies RelayHookDeliveryProofPayload, }); - }).pipe(Effect.orDie); + }, + (effect, _input) => effect.pipe(Effect.orDie), + ); const handle = Effect.fn("relay.hooks.forward")(function* ( request: HttpServerRequest.HttpServerRequest, @@ -354,37 +356,37 @@ const make = Effect.gen(function* () { headers: { ...forwardedHeaders(request.headers), [RELAY_HOOK_DELIVERY_HEADER]: proof }, body: body.success, }; - const holdOrFail = (status: 503 | 504, error: string) => - Effect.gen(function* () { - if (!endpoint.holdWhileOffline) { - yield* outcome(error); - return errorResponse(status, error); - } - const stored = yield* inbox - .hold({ - endpointKey: parsed.endpointKey, - baseUrl: endpoint.httpBaseUrl, - hook, - }) - .pipe( - Effect.catch((cause) => - Effect.logWarning("Could not hold webhook request", { - environmentId: endpoint.environmentId, - errorTag: cause._tag, - }).pipe(Effect.as(null)), - ), - ); - if (stored === null) { - yield* outcome(error); - return errorResponse(status, error); - } - if (!stored) { - yield* outcome("inbox_full"); - return errorResponse(503, "inbox_full"); - } - yield* outcome("held"); - return HttpServerResponse.jsonUnsafe({ queued: true }, { status: 202 }); - }); + // Keep offline outcomes on the enclosing relay.hooks.forward span. + const holdOrFail = Effect.fnUntraced(function* (status: 503 | 504, error: string) { + if (!endpoint.holdWhileOffline) { + yield* outcome(error); + return errorResponse(status, error); + } + const stored = yield* inbox + .hold({ + endpointKey: parsed.endpointKey, + baseUrl: endpoint.httpBaseUrl, + hook, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Could not hold webhook request", { + environmentId: endpoint.environmentId, + errorTag: cause._tag, + }).pipe(Effect.as(null)), + ), + ); + if (stored === null) { + yield* outcome(error); + return errorResponse(status, error); + } + if (!stored) { + yield* outcome("inbox_full"); + return errorResponse(503, "inbox_full"); + } + yield* outcome("held"); + return HttpServerResponse.jsonUnsafe({ queued: true }, { status: 202 }); + }); const upstream = yield* sendUpstream(endpoint.httpBaseUrl, hook).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/infra/relay/src/hooks/HookInboxObject.ts b/infra/relay/src/hooks/HookInboxObject.ts index 2d1c8ef97682..409395eab0e7 100644 --- a/infra/relay/src/hooks/HookInboxObject.ts +++ b/infra/relay/src/hooks/HookInboxObject.ts @@ -89,33 +89,39 @@ export const layer = HookInboxObject.make( effect.pipe(Effect.provide(layerSql), Effect.orDie); yield* run(HookInboxStore.migrate); - const scheduleBy = (at: number) => - Effect.gen(function* () { - const current = yield* state.storage.getAlarm(); - if (current === null || current > at) yield* state.storage.setAlarm(at); - }); + const scheduleBy = Effect.fn("HookInboxObject.scheduleBy")(function* (at: number) { + const current = yield* state.storage.getAlarm(); + if (current === null || current > at) yield* state.storage.setAlarm(at); + }); + // withInboxSpan supplies each method's root span; avoid a second wrapper span. return { - hold: (hook: HookInboxStore.HeldHook, baseUrl: string) => - Effect.gen(function* () { + hold: Effect.fnUntraced( + function* (hook: HookInboxStore.HeldHook, baseUrl: string) { const dueAt = yield* run(HookInboxStore.hold(hook, baseUrl)); yield* Effect.annotateCurrentSpan({ "relay.inbox.stored": dueAt !== null }); if (dueAt === null) return false; yield* scheduleBy(dueAt); return true; - }).pipe(withInboxSpan("relay.inbox.hold", inboxId)), - wake: (baseUrl: string) => - Effect.gen(function* () { + }, + withInboxSpan("relay.inbox.hold", inboxId), + ), + wake: Effect.fnUntraced( + function* (baseUrl: string) { const pending = yield* run(HookInboxStore.wake(baseUrl)); yield* Effect.annotateCurrentSpan({ "relay.inbox.pending": pending }); if (pending) yield* state.storage.setAlarm(yield* Clock.currentTimeMillis); return pending; - }).pipe(withInboxSpan("relay.inbox.wake", inboxId)), - clear: () => - Effect.gen(function* () { + }, + withInboxSpan("relay.inbox.wake", inboxId), + ), + clear: Effect.fnUntraced( + function* () { yield* run(HookInboxStore.clear); yield* state.storage.deleteAlarm(); - }).pipe(withInboxSpan("relay.inbox.clear", inboxId)), + }, + withInboxSpan("relay.inbox.clear", inboxId), + ), alarm: () => run(HookInboxStore.deliverDue(deliver)).pipe( // A wake during this run may already have asked for an earlier diff --git a/infra/relay/src/hooks/HookInboxStore.test.ts b/infra/relay/src/hooks/HookInboxStore.test.ts index f48a3aa3b07d..4667b21db22b 100644 --- a/infra/relay/src/hooks/HookInboxStore.test.ts +++ b/infra/relay/src/hooks/HookInboxStore.test.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import * as HookInboxStore from "./HookInboxStore.ts"; @@ -44,6 +45,35 @@ const withInbox = (effect: Effect.Effect) => ); describe("HookInboxStore", () => { + it.effect.each(["delivered", "unreachable"] as const)( + "keeps %s delivery results on the operation span", + (outcome) => + withInbox( + Effect.gen(function* () { + yield* HookInboxStore.hold(yield* hook("traced"), BASE_URL); + const spans: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + yield* HookInboxStore.deliverDue(deliverer(() => outcome).send).pipe( + Effect.withTracer(tracer), + ); + const run = spans.find((span) => span.name === "HookInboxStore.deliverDue"); + expect(run?.attributes.get("relay.inbox.run_result")).toBe( + outcome === "delivered" ? "drained" : "unreachable", + ); + expect(run?.attributes.get("relay.inbox.sent")).toBe(1); + expect(run?.attributes.get("relay.inbox.delivered")).toBe( + outcome === "delivered" ? 1 : 0, + ); + }), + ), + ); + it.effect.each(["delivered", "busy", "unreachable"] as const)( "stops a prefetched batch cleared during a %s send", (outcome) => diff --git a/infra/relay/src/hooks/HookInboxStore.ts b/infra/relay/src/hooks/HookInboxStore.ts index 3edd2be17d61..aa7aa0d4e25f 100644 --- a/infra/relay/src/hooks/HookInboxStore.ts +++ b/infra/relay/src/hooks/HookInboxStore.ts @@ -132,19 +132,21 @@ export const migrate = Effect.gen(function* () { `; }); -const setTarget = (baseUrl: string, options: { readonly resetFailures: boolean }) => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* options.resetFailures - ? sql` +const setTarget = Effect.fn("HookInboxStore.setTarget")(function* ( + baseUrl: string, + options: { readonly resetFailures: boolean }, +) { + const sql = yield* SqlClient.SqlClient; + yield* options.resetFailures + ? sql` INSERT INTO held_hooks_target (id, base_url) VALUES (1, ${baseUrl}) ON CONFLICT (id) DO UPDATE SET base_url = excluded.base_url, failures = 0 ` - : sql` + : sql` INSERT INTO held_hooks_target (id, base_url) VALUES (1, ${baseUrl}) ON CONFLICT (id) DO UPDATE SET base_url = excluded.base_url `; - }); +}); const readTarget = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -218,18 +220,17 @@ const backlogSize = Effect.gen(function* () { return rows[0] ?? { count: 0, bytes: 0 }; }); -const refusalReason = (hook: HeldHook) => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const existing = yield* sql<{ readonly id: string }>` +const refusalReason = Effect.fn("HookInboxStore.refusalReason")(function* (hook: HeldHook) { + const sql = yield* SqlClient.SqlClient; + const existing = yield* sql<{ readonly id: string }>` SELECT id FROM held_hooks WHERE id = ${hook.id} `; - if (existing.length > 0) return "already_held"; - const backlog = yield* backlogSize; - if (backlog.count >= HOOK_INBOX_MAX_REQUESTS) return "max_requests"; - if (backlog.bytes + hook.body.byteLength > HOOK_INBOX_MAX_BYTES) return "max_bytes"; - return "max_per_hook"; - }); + if (existing.length > 0) return "already_held"; + const backlog = yield* backlogSize; + if (backlog.count >= HOOK_INBOX_MAX_REQUESTS) return "max_requests"; + if (backlog.bytes + hook.body.byteLength > HOOK_INBOX_MAX_BYTES) return "max_bytes"; + return "max_per_hook"; +}); export const wake = Effect.fn("HookInboxStore.wake")(function* (baseUrl: string) { if (!(yield* hasPending)) return false; @@ -277,22 +278,22 @@ export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* ( let longestWaitMs = 0; const busyHooks = new Set(); - const annotateRun = (result: string) => - Effect.gen(function* () { - const backlog = yield* backlogSize; - yield* Effect.annotateCurrentSpan({ - "relay.inbox.run_result": result, - "relay.inbox.sent": sent, - "relay.inbox.delivered": delivered, - "relay.inbox.busy_hooks": busyHooks.size, - "relay.inbox.expired": expired.length, - "relay.inbox.unreadable": unreadable, - "relay.inbox.consecutive_failures": failures, - "relay.inbox.longest_wait_ms": longestWaitMs, - "relay.inbox.held_count": backlog.count, - "relay.inbox.held_bytes": backlog.bytes, - }); + // Delivery results and counts belong to the enclosing deliverDue span. + const annotateRun = Effect.fnUntraced(function* (result: string) { + const backlog = yield* backlogSize; + yield* Effect.annotateCurrentSpan({ + "relay.inbox.run_result": result, + "relay.inbox.sent": sent, + "relay.inbox.delivered": delivered, + "relay.inbox.busy_hooks": busyHooks.size, + "relay.inbox.expired": expired.length, + "relay.inbox.unreadable": unreadable, + "relay.inbox.consecutive_failures": failures, + "relay.inbox.longest_wait_ms": longestWaitMs, + "relay.inbox.held_count": backlog.count, + "relay.inbox.held_bytes": backlog.bytes, }); + }); const stillHeld = (seq: number) => sql<{ readonly seq: number }>`SELECT seq FROM held_hooks WHERE seq = ${seq}`.pipe( Effect.map((rows) => rows.length > 0), diff --git a/infra/relay/src/hooks/upstream.ts b/infra/relay/src/hooks/upstream.ts index 902cc3f3b80d..a0d3a4780fc4 100644 --- a/infra/relay/src/hooks/upstream.ts +++ b/infra/relay/src/hooks/upstream.ts @@ -73,43 +73,45 @@ const readCapped = (response: HttpClientResponse.HttpClientResponse) => * Sends a webhook request through the environment's tunnel. Fails when the * environment cannot be reached, and succeeds with None on timeout. */ -export const sendUpstream = (baseUrl: string, hook: UpstreamHook) => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; - // The environment's span joins this trace. Set by hand: the client span - // that would propagate it is off, because it records the token in url.full. - const parent = yield* Effect.currentSpan.pipe(Effect.option); - const headers: Record = { - ...hook.headers, - ...(Option.isSome(parent) ? HttpTraceContext.toHeaders(parent.value) : {}), - [RELAY_DELIVERY_ID_HEADER]: hook.id, - [RELAY_RECEIVED_AT_HEADER]: hook.receivedAt, - }; - let request = HttpClientRequest.make(hook.method as "GET" | "POST" | "PUT" | "PATCH")( - `${base}api/hooks/${hook.rawHookId}/${hook.rawToken}${hook.query ? `?${hook.query}` : ""}`, - { headers }, - ); - if (HttpMethod.hasBody(request.method)) { - request = HttpClientRequest.bodyUint8Array(request, hook.body, headers["content-type"]); - } - return yield* httpClient.execute(request).pipe( - Effect.flatMap((response) => - readCapped(response).pipe( - Effect.map((body): UpstreamResponse => { - const outcome = response.headers["x-t3-hook-outcome"]; - return { - status: response.status, - contentType: response.headers["content-type"], - outcome: outcome !== undefined && OUTCOME_PATTERN.test(outcome) ? outcome : undefined, - body, - }; - }), - ), +export const sendUpstream = Effect.fn("upstream.sendUpstream")(function* ( + baseUrl: string, + hook: UpstreamHook, +) { + const httpClient = yield* HttpClient.HttpClient; + const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + // The environment's span joins this trace. Set by hand: the client span + // that would propagate it is off, because it records the token in url.full. + const parent = yield* Effect.currentSpan.pipe(Effect.option); + const headers: Record = { + ...hook.headers, + ...(Option.isSome(parent) ? HttpTraceContext.toHeaders(parent.value) : {}), + [RELAY_DELIVERY_ID_HEADER]: hook.id, + [RELAY_RECEIVED_AT_HEADER]: hook.receivedAt, + }; + let request = HttpClientRequest.make(hook.method as "GET" | "POST" | "PUT" | "PATCH")( + `${base}api/hooks/${hook.rawHookId}/${hook.rawToken}${hook.query ? `?${hook.query}` : ""}`, + { headers }, + ); + if (HttpMethod.hasBody(request.method)) { + request = HttpClientRequest.bodyUint8Array(request, hook.body, headers["content-type"]); + } + return yield* httpClient.execute(request).pipe( + Effect.flatMap((response) => + readCapped(response).pipe( + Effect.map((body): UpstreamResponse => { + const outcome = response.headers["x-t3-hook-outcome"]; + return { + status: response.status, + contentType: response.headers["content-type"], + outcome: outcome !== undefined && OUTCOME_PATTERN.test(outcome) ? outcome : undefined, + body, + }; + }), ), - withoutRedirects, - // The client span would record url.full, which carries the token. - Effect.provideService(HttpClient.TracerDisabledWhen, () => true), - Effect.timeoutOption(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS)), - ); - }); + ), + withoutRedirects, + // The client span would record url.full, which carries the token. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + Effect.timeoutOption(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS)), + ); +}); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 1db129ab834b..49f3d33814c7 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -207,51 +207,51 @@ const relayRequestDeadline = ( const SENDER_TRACE_HEADER = /^(traceparent|tracestate|b3|x-b3-.*)$/i; -export const traceRelayHttpRequest = ( +// The method-named server span is created below; this wrapper must not add another span. +export const traceRelayHttpRequest = Effect.fnUntraced(function* ( httpEffect: Effect.Effect< HttpServerResponse.HttpServerResponse, E, HttpServerRequest.HttpServerRequest | R >, -) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const traced = appendRelayTraceContextResponseHeader.pipe( - Effect.andThen(relayRequestDeadline(httpEffect)), - ); - if (!isRelayHookPath(request.url)) { - return yield* HttpMiddleware.tracer(traced).pipe( - Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false), - // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. - Effect.ensuring(Effect.yieldNow), - ); - } - // Hook URLs carry a secret token: the tracer and deadline log see a redacted - // request, while the route itself still receives the original. A webhook - // sender's trace context is dropped, so it cannot pick the trace our relay - // and environment spans land in. - const redacted = request.modify({ - url: redactRelayHookUrl(request.url), - headers: Headers.removeMany( - request.headers, - Object.keys(request.headers).filter((name) => SENDER_TRACE_HEADER.test(name)), - ), - }); - return yield* HttpMiddleware.tracer( - appendRelayTraceContextResponseHeader.pipe( - Effect.andThen( - relayRequestDeadline( - httpEffect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request)), - RELAY_HOOK_REQUEST_DEADLINE_MS, - ), - ), - ), - ).pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, redacted), +) { + const request = yield* HttpServerRequest.HttpServerRequest; + const traced = appendRelayTraceContextResponseHeader.pipe( + Effect.andThen(relayRequestDeadline(httpEffect)), + ); + if (!isRelayHookPath(request.url)) { + return yield* HttpMiddleware.tracer(traced).pipe( Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false), + // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. Effect.ensuring(Effect.yieldNow), ); + } + // Hook URLs carry a secret token: the tracer and deadline log see a redacted + // request, while the route itself still receives the original. A webhook + // sender's trace context is dropped, so it cannot pick the trace our relay + // and environment spans land in. + const redacted = request.modify({ + url: redactRelayHookUrl(request.url), + headers: Headers.removeMany( + request.headers, + Object.keys(request.headers).filter((name) => SENDER_TRACE_HEADER.test(name)), + ), }); + return yield* HttpMiddleware.tracer( + appendRelayTraceContextResponseHeader.pipe( + Effect.andThen( + relayRequestDeadline( + httpEffect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request)), + RELAY_HOOK_REQUEST_DEADLINE_MS, + ), + ), + ), + ).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, redacted), + Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false), + Effect.ensuring(Effect.yieldNow), + ); +}); const layerWebhookHeaderRedaction = Layer.effect( Headers.CurrentRedactedNames, @@ -1147,8 +1147,9 @@ export const layerServerApi = HttpApiBuilder.group( const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; const heldHooks = yield* HeldHooks.HeldHooks; - const requireOwnEnvironment = (environmentId: string) => - Effect.gen(function* () { + const requireOwnEnvironment = + /* Request instrumentation already owns the span; keep this helper in that context. */ + Effect.fnUntraced(function* (environmentId: string) { const principal = yield* RelayEnvironmentPrincipal; if (principal.environmentId !== environmentId) { return yield* new HttpApiError.Unauthorized({}); diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 25a53b73d7d9..c56106871385 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -230,12 +230,12 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules")); }); -const removeNestedBinDirectories = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string, -): Effect.Effect => - Effect.gen(function* () { +const removeNestedBinDirectories = Effect.fn("build-cli-archive.removeNestedBinDirectories")( + function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + ): Effect.fn.Return { const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries) { const child = path.join(root, entry); @@ -248,7 +248,8 @@ const removeNestedBinDirectories = ( yield* removeNestedBinDirectories(fs, path, child); } } - }); + }, +); const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { const fs = yield* FileSystem.FileSystem; diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5688143ebc5f..a8c535795cb0 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2066,11 +2066,11 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin // probe cannot resolve through staging and Windows needs no symlink privilege. yield* fs.copy(source, destination); - const restoreRelativeSymlinks = ( - sourceDirectory: string, - destinationDirectory: string, - ): Effect.Effect => - Effect.gen(function* () { + const restoreRelativeSymlinks = Effect.fn("build-desktop-artifact.restoreRelativeSymlinks")( + function* ( + sourceDirectory: string, + destinationDirectory: string, + ): Effect.fn.Return { for (const entry of yield* fs.readDirectory(sourceDirectory)) { const sourceEntry = path.join(sourceDirectory, entry); const destinationEntry = path.join(destinationDirectory, entry); @@ -2107,7 +2107,8 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin } } } - }); + }, + ); yield* restoreRelativeSymlinks(source, destination); }, @@ -2373,69 +2374,69 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp ); }); -function generateMacIconSet( +const generateMacIconSet = Effect.fn("build-desktop-artifact.generateMacIconSet")(function* ( sourcePng: string, targetIcns: string, tmpRoot: string, path: Path.Path, verbose: boolean, ) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const iconsetDir = path.join(tmpRoot, "icon.iconset"); - yield* fs.makeDirectory(iconsetDir, { recursive: true }); + const fs = yield* FileSystem.FileSystem; + const iconsetDir = path.join(tmpRoot, "icon.iconset"); + yield* fs.makeDirectory(iconsetDir, { recursive: true }); - const iconSizes = [16, 32, 128, 256, 512] as const; - for (const size of iconSizes) { - yield* runCommand( - ChildProcess.make( - {}, - )`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`, - { label: `sips icon ${size}x${size}`, verbose }, - ); + const iconSizes = [16, 32, 128, 256, 512] as const; + for (const size of iconSizes) { + yield* runCommand( + ChildProcess.make( + {}, + )`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`, + { label: `sips icon ${size}x${size}`, verbose }, + ); - const retinaSize = size * 2; - yield* runCommand( - ChildProcess.make( - {}, - )`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`, - { label: `sips icon ${size}x${size}@2x`, verbose }, - ); - } + const retinaSize = size * 2; + yield* runCommand( + ChildProcess.make( + {}, + )`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`, + { label: `sips icon ${size}x${size}@2x`, verbose }, + ); + } - yield* runCommand(ChildProcess.make({})`iconutil -c icns ${iconsetDir} -o ${targetIcns}`, { - label: "iconutil icns", - verbose, - }); + yield* runCommand(ChildProcess.make({})`iconutil -c icns ${iconsetDir} -o ${targetIcns}`, { + label: "iconutil icns", + verbose, }); -} - -function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - if (!(yield* fs.exists(sourcePng))) { - return yield* new DesktopIconSourceMissingError({ - platform: "mac", - sourcePath: sourcePng, - }); - } +}); - const tmpRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "t3code-icon-build-", +const stageMacIcons = Effect.fn("build-desktop-artifact.stageMacIcons")(function* ( + stageResourcesDir: string, + sourcePng: string, + verbose: boolean, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (!(yield* fs.exists(sourcePng))) { + return yield* new DesktopIconSourceMissingError({ + platform: "mac", + sourcePath: sourcePng, }); + } - const iconPngPath = path.join(stageResourcesDir, "icon.png"); - const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); + const tmpRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-icon-build-", + }); - yield* runCommand(ChildProcess.make({})`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`, { - label: "sips mac icon", - verbose, - }); + const iconPngPath = path.join(stageResourcesDir, "icon.png"); + const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); - yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose); + yield* runCommand(ChildProcess.make({})`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`, { + label: "sips mac icon", + verbose, }); -} + + yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose); +}); export const stageDesktopDmgBackground = Effect.fn("stageDesktopDmgBackground")(function* ( stageResourcesDir: string, @@ -2470,32 +2471,34 @@ export const stageDesktopDmgBackground = Effect.fn("stageDesktopDmgBackground")( } }); -function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - if (!(yield* fs.exists(sourcePng))) { - return yield* new DesktopIconSourceMissingError({ - platform: "linux", - sourcePath: sourcePng, - }); - } +const stageLinuxIcons = Effect.fn("build-desktop-artifact.stageLinuxIcons")(function* ( + stageResourcesDir: string, + sourcePng: string, + verbose: boolean, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (!(yield* fs.exists(sourcePng))) { + return yield* new DesktopIconSourceMissingError({ + platform: "linux", + sourcePath: sourcePng, + }); + } - const iconPath = path.join(stageResourcesDir, "icon.png"); - yield* fs.copyFile(sourcePng, iconPath); + const iconPath = path.join(stageResourcesDir, "icon.png"); + yield* fs.copyFile(sourcePng, iconPath); - const iconsDir = path.join(stageResourcesDir, "icons"); - yield* fs.makeDirectory(iconsDir, { recursive: true }); - for (const iconSize of LINUX_ICON_SIZES) { - yield* stageLinuxIconSize( - sourcePng, - path.join(iconsDir, `${iconSize}x${iconSize}.png`), - iconSize, - verbose, - ); - } - }); -} + const iconsDir = path.join(stageResourcesDir, "icons"); + yield* fs.makeDirectory(iconsDir, { recursive: true }); + for (const iconSize of LINUX_ICON_SIZES) { + yield* stageLinuxIconSize( + sourcePng, + path.join(iconsDir, `${iconSize}x${iconSize}.png`), + iconSize, + verbose, + ); + } +}); export function stageLinuxIconSize( sourcePng: string, @@ -2531,24 +2534,25 @@ export function stageLinuxIconSize( ); } -function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - if (!(yield* fs.exists(sourceIco))) { - return yield* new DesktopIconSourceMissingError({ - platform: "win", - sourcePath: sourceIco, - }); - } +const stageWindowsIcons = Effect.fn("build-desktop-artifact.stageWindowsIcons")(function* ( + stageResourcesDir: string, + sourceIco: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (!(yield* fs.exists(sourceIco))) { + return yield* new DesktopIconSourceMissingError({ + platform: "win", + sourcePath: sourceIco, + }); + } - const iconPath = path.join(stageResourcesDir, "icon.ico"); - yield* fs.copyFile(sourceIco, iconPath); - }); -} + const iconPath = path.join(stageResourcesDir, "icon.ico"); + yield* fs.copyFile(sourceIco, iconPath); +}); -function validateBundledClientAssets(clientDir: string) { - return Effect.gen(function* () { +const validateBundledClientAssets = Effect.fn("build-desktop-artifact.validateBundledClientAssets")( + function* (clientDir: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const indexPath = path.join(clientDir, "index.html"); @@ -2580,8 +2584,8 @@ function validateBundledClientAssets(clientDir: string) { missingFiles: missing, }); } - }); -} + }, +); export function resolveDesktopRuntimeDependencies( dependencies: Record | undefined, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 31f521a96d8a..efedef4b4f3c 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -255,18 +255,18 @@ export function resolveOffset(config: { return Effect.succeed({ offset: 0, source: "default ports" }); } -function resolveBaseDir(baseDir: string | undefined): Effect.Effect { - return Effect.gen(function* () { - const path = yield* Path.Path; - const configured = baseDir?.trim(); - - if (configured) { - return path.resolve(configured); - } +const resolveBaseDir = Effect.fn("dev-runner.resolveBaseDir")(function* ( + baseDir: string | undefined, +): Effect.fn.Return { + const path = yield* Path.Path; + const configured = baseDir?.trim(); + + if (configured) { + return path.resolve(configured); + } - return yield* DEFAULT_T3_HOME; - }); -} + return yield* DEFAULT_T3_HOME; +}); interface CreateDevRunnerEnvInput { readonly mode: DevMode; @@ -282,7 +282,7 @@ interface CreateDevRunnerEnvInput { readonly devUrl: URL | undefined; } -export function createDevRunnerEnv({ +export const createDevRunnerEnv = Effect.fn("dev-runner.createDevRunnerEnv")(function* ({ mode, baseEnv, serverOffset, @@ -294,111 +294,109 @@ export function createDevRunnerEnv({ host, port, devUrl, -}: CreateDevRunnerEnvInput): Effect.Effect { - return Effect.gen(function* () { - const serverPort = port ?? BASE_SERVER_PORT + serverOffset; - const webPort = BASE_WEB_PORT + webOffset; - const configuredBaseDir = t3Home?.trim() || undefined; - const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); - const isDesktopMode = mode === "dev:desktop"; - - const output: NodeJS.ProcessEnv = { - ...baseEnv, - PORT: String(webPort), - VITE_DEV_SERVER_URL: - devUrl?.toString() ?? - `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, - }; - - if (configuredBaseDir !== undefined) { - output.T3CODE_HOME = resolvedBaseDir; - } else { - delete output.T3CODE_HOME; - } +}: CreateDevRunnerEnvInput): Effect.fn.Return { + const serverPort = port ?? BASE_SERVER_PORT + serverOffset; + const webPort = BASE_WEB_PORT + webOffset; + const configuredBaseDir = t3Home?.trim() || undefined; + const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); + const isDesktopMode = mode === "dev:desktop"; + + const output: NodeJS.ProcessEnv = { + ...baseEnv, + PORT: String(webPort), + VITE_DEV_SERVER_URL: + devUrl?.toString() ?? + `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, + }; - // A dev-runner server is never launcher-managed. When the shell that runs - // this script was itself spawned by the machine's managed t3 service (an - // agent working inside T3 Code), these leak through and the child server - // fails startup with "The service launcher started a different t3 version" - // (serviceLauncherClient.ts resolveStartup). - delete output.T3_SERVICE_LAUNCHER_CONTEXT; - delete output.T3_BOOT_SERVICE_UNIT; - - if (!isDesktopMode) { - output.T3CODE_PORT = String(serverPort); - // HOST is Vite's own bind address, and the desktop branch below is the - // only place we set it. An inherited one (an exported HOST, a container, - // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin - // its HMR socket to that address — see the `explicitHost` gate in - // apps/web/vite.config.ts. - delete output.HOST; - if (mode === "dev" || mode === "dev:web") { - // Browser dev is single-origin: everything (including /ws) is proxied - // through Vite, so the client must resolve its backend from - // window.location.origin rather than a baked-in localhost URL. - delete output.VITE_HTTP_URL; - delete output.VITE_WS_URL; - // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv, - // which merges `.env`/`.env.local` *under* this env, so a developer - // with either URL in their `.env` would get it back and silently lose - // single-origin mode. - output.T3CODE_SINGLE_ORIGIN_DEV = "1"; - } else { - output.VITE_HTTP_URL = `http://localhost:${serverPort}`; - output.VITE_WS_URL = `ws://localhost:${serverPort}`; - delete output.T3CODE_SINGLE_ORIGIN_DEV; - } + if (configuredBaseDir !== undefined) { + output.T3CODE_HOME = resolvedBaseDir; + } else { + delete output.T3CODE_HOME; + } + + // A dev-runner server is never launcher-managed. When the shell that runs + // this script was itself spawned by the machine's managed t3 service (an + // agent working inside T3 Code), these leak through and the child server + // fails startup with "The service launcher started a different t3 version" + // (serviceLauncherClient.ts resolveStartup). + delete output.T3_SERVICE_LAUNCHER_CONTEXT; + delete output.T3_BOOT_SERVICE_UNIT; + + if (!isDesktopMode) { + output.T3CODE_PORT = String(serverPort); + // HOST is Vite's own bind address, and the desktop branch below is the + // only place we set it. An inherited one (an exported HOST, a container, + // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin + // its HMR socket to that address — see the `explicitHost` gate in + // apps/web/vite.config.ts. + delete output.HOST; + if (mode === "dev" || mode === "dev:web") { + // Browser dev is single-origin: everything (including /ws) is proxied + // through Vite, so the client must resolve its backend from + // window.location.origin rather than a baked-in localhost URL. + delete output.VITE_HTTP_URL; + delete output.VITE_WS_URL; + // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv, + // which merges `.env`/`.env.local` *under* this env, so a developer + // with either URL in their `.env` would get it back and silently lose + // single-origin mode. + output.T3CODE_SINGLE_ORIGIN_DEV = "1"; } else { - output.T3CODE_PORT = String(serverPort); - output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; - output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; - // Desktop pins the renderer to loopback on purpose; an ambient marker - // must not make Vite drop those URLs. + output.VITE_HTTP_URL = `http://localhost:${serverPort}`; + output.VITE_WS_URL = `ws://localhost:${serverPort}`; delete output.T3CODE_SINGLE_ORIGIN_DEV; - delete output.T3CODE_MODE; - delete output.T3CODE_NO_BROWSER; - delete output.T3CODE_HOST; - delete output.T3CODE_DEV_AUTH_TOKEN; } + } else { + output.T3CODE_PORT = String(serverPort); + output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; + output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; + // Desktop pins the renderer to loopback on purpose; an ambient marker + // must not make Vite drop those URLs. + delete output.T3CODE_SINGLE_ORIGIN_DEV; + delete output.T3CODE_MODE; + delete output.T3CODE_NO_BROWSER; + delete output.T3CODE_HOST; + delete output.T3CODE_DEV_AUTH_TOKEN; + } - if (!isDesktopMode && host !== undefined) { - output.T3CODE_HOST = host; - } + if (!isDesktopMode && host !== undefined) { + output.T3CODE_HOST = host; + } - if (!isDesktopMode) { - output.T3CODE_NO_BROWSER = browser === true ? "0" : "1"; - } + if (!isDesktopMode) { + output.T3CODE_NO_BROWSER = browser === true ? "0" : "1"; + } - if (autoBootstrapProjectFromCwd !== undefined) { - output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; - } else { - delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; - } + if (autoBootstrapProjectFromCwd !== undefined) { + output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; + } else { + delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; + } - if (logWebSocketEvents !== undefined) { - output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0"; - } else { - delete output.T3CODE_LOG_WS_EVENTS; - } + if (logWebSocketEvents !== undefined) { + output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0"; + } else { + delete output.T3CODE_LOG_WS_EVENTS; + } - if (mode === "dev") { - output.T3CODE_MODE = "web"; - delete output.T3CODE_DESKTOP_WS_URL; - } + if (mode === "dev") { + output.T3CODE_MODE = "web"; + delete output.T3CODE_DESKTOP_WS_URL; + } - if (mode === "dev:server" || mode === "dev:web") { - output.T3CODE_MODE = "web"; - delete output.T3CODE_DESKTOP_WS_URL; - } + if (mode === "dev:server" || mode === "dev:web") { + output.T3CODE_MODE = "web"; + delete output.T3CODE_DESKTOP_WS_URL; + } - if (isDesktopMode) { - output.HOST = DESKTOP_DEV_LOOPBACK_HOST; - delete output.T3CODE_DESKTOP_WS_URL; - } + if (isDesktopMode) { + output.HOST = DESKTOP_DEV_LOOPBACK_HOST; + delete output.T3CODE_DESKTOP_WS_URL; + } - return output; - }); -} + return output; +}); function portPairForOffset(offset: number): { readonly serverPort: number; @@ -410,12 +408,12 @@ function portPairForOffset(offset: number): { }; } -export function checkPortAvailabilityOnHosts( - port: number, - hosts: ReadonlyArray, - canListenOnHost: (port: number, host: string) => Effect.Effect, -): Effect.Effect { - return Effect.gen(function* () { +export const checkPortAvailabilityOnHosts = Effect.fn("dev-runner.checkPortAvailabilityOnHosts")( + function* ( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Effect.Effect, + ): Effect.fn.Return { for (const host of hosts) { if (!(yield* canListenOnHost(port, host))) { return false; @@ -423,8 +421,8 @@ export function checkPortAvailabilityOnHosts( } return true; - }); -} + }, +); export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray { const host = configuredHost?.trim(); @@ -454,61 +452,61 @@ interface FindFirstAvailableOffsetInput { readonly checkPortAvailability?: PortAvailabilityCheck; } -export function findFirstAvailableOffset({ +export const findFirstAvailableOffset = Effect.fn("dev-runner.findFirstAvailableOffset")(function* < + R = NetService.NetService, +>({ startOffset, requireServerPort, requireWebPort, checkPortAvailability, -}: FindFirstAvailableOffsetInput): Effect.Effect { - return Effect.gen(function* () { - const checkPort = (checkPortAvailability ?? - defaultCheckPortAvailability) as PortAvailabilityCheck; - - for (let candidate = startOffset; ; candidate += 1) { - const { serverPort, webPort } = portPairForOffset(candidate); - const serverPortOutOfRange = serverPort > MAX_PORT; - const webPortOutOfRange = webPort > MAX_PORT; - - if ( - (requireServerPort && serverPortOutOfRange) || - (requireWebPort && webPortOutOfRange) || - (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) - ) { - break; - } +}: FindFirstAvailableOffsetInput): Effect.fn.Return { + const checkPort = (checkPortAvailability ?? + defaultCheckPortAvailability) as PortAvailabilityCheck; - if (requireWebPort && !isBrowserAllowedPort(webPort)) { - continue; - } + for (let candidate = startOffset; ; candidate += 1) { + const { serverPort, webPort } = portPairForOffset(candidate); + const serverPortOutOfRange = serverPort > MAX_PORT; + const webPortOutOfRange = webPort > MAX_PORT; - const checks: Array> = []; - if (requireServerPort) { - checks.push(checkPort(serverPort, "server")); - } - if (requireWebPort) { - checks.push(checkPort(webPort, "web")); - } + if ( + (requireServerPort && serverPortOutOfRange) || + (requireWebPort && webPortOutOfRange) || + (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) + ) { + break; + } - if (checks.length === 0) { - return candidate; - } + if (requireWebPort && !isBrowserAllowedPort(webPort)) { + continue; + } - const availability = yield* Effect.all(checks); - if (availability.every(Boolean)) { - return candidate; - } + const checks: Array> = []; + if (requireServerPort) { + checks.push(checkPort(serverPort, "server")); + } + if (requireWebPort) { + checks.push(checkPort(webPort, "web")); } - return yield* new DevRunnerPortExhaustedError({ - startOffset, - requireServerPort, - requireWebPort, - baseServerPort: BASE_SERVER_PORT, - baseWebPort: BASE_WEB_PORT, - maximumPort: MAX_PORT, - }); + if (checks.length === 0) { + return candidate; + } + + const availability = yield* Effect.all(checks); + if (availability.every(Boolean)) { + return candidate; + } + } + + return yield* new DevRunnerPortExhaustedError({ + startOffset, + requireServerPort, + requireWebPort, + baseServerPort: BASE_SERVER_PORT, + baseWebPort: BASE_WEB_PORT, + maximumPort: MAX_PORT, }); -} +}); interface ResolveModePortOffsetsInput { readonly mode: DevMode; @@ -518,59 +516,59 @@ interface ResolveModePortOffsetsInput { readonly checkPortAvailability?: PortAvailabilityCheck; } -export function resolveModePortOffsets({ +export const resolveModePortOffsets = Effect.fn("dev-runner.resolveModePortOffsets")(function* < + R = NetService.NetService, +>({ mode, startOffset, hasExplicitServerPort, hasExplicitDevUrl, checkPortAvailability, -}: ResolveModePortOffsetsInput): Effect.Effect< +}: ResolveModePortOffsetsInput): Effect.fn.Return< { readonly serverOffset: number; readonly webOffset: number }, DevRunnerPortExhaustedError, R > { - return Effect.gen(function* () { - const checkPort = (checkPortAvailability ?? - defaultCheckPortAvailability) as PortAvailabilityCheck; - - if (mode === "dev:web") { - if (hasExplicitDevUrl) { - return { serverOffset: startOffset, webOffset: startOffset }; - } + const checkPort = (checkPortAvailability ?? + defaultCheckPortAvailability) as PortAvailabilityCheck; - const webOffset = yield* findFirstAvailableOffset({ - startOffset, - requireServerPort: false, - requireWebPort: true, - checkPortAvailability: checkPort, - }); - return { serverOffset: startOffset, webOffset }; + if (mode === "dev:web") { + if (hasExplicitDevUrl) { + return { serverOffset: startOffset, webOffset: startOffset }; } - if (mode === "dev:server") { - if (hasExplicitServerPort) { - return { serverOffset: startOffset, webOffset: startOffset }; - } + const webOffset = yield* findFirstAvailableOffset({ + startOffset, + requireServerPort: false, + requireWebPort: true, + checkPortAvailability: checkPort, + }); + return { serverOffset: startOffset, webOffset }; + } - const serverOffset = yield* findFirstAvailableOffset({ - startOffset, - requireServerPort: true, - requireWebPort: false, - checkPortAvailability: checkPort, - }); - return { serverOffset, webOffset: serverOffset }; + if (mode === "dev:server") { + if (hasExplicitServerPort) { + return { serverOffset: startOffset, webOffset: startOffset }; } - const sharedOffset = yield* findFirstAvailableOffset({ + const serverOffset = yield* findFirstAvailableOffset({ startOffset, - requireServerPort: !hasExplicitServerPort, - requireWebPort: !hasExplicitDevUrl, + requireServerPort: true, + requireWebPort: false, checkPortAvailability: checkPort, }); + return { serverOffset, webOffset: serverOffset }; + } - return { serverOffset: sharedOffset, webOffset: sharedOffset }; + const sharedOffset = yield* findFirstAvailableOffset({ + startOffset, + requireServerPort: !hasExplicitServerPort, + requireWebPort: !hasExplicitDevUrl, + checkPortAvailability: checkPort, }); -} + + return { serverOffset: sharedOffset, webOffset: sharedOffset }; +}); interface DevRunnerCliInput { readonly mode: DevMode; @@ -586,198 +584,196 @@ interface DevRunnerCliInput { readonly runArgs: ReadonlyArray; } -export function runDevRunnerWithInput(input: DevRunnerCliInput) { - return Effect.gen(function* () { - const { portOffset, devInstance } = yield* OffsetConfig.pipe( - Effect.mapError( - (cause) => - new DevRunnerConfigurationError({ - configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"], - cause, - }), - ), - ); +export const runDevRunnerWithInput = Effect.fn("dev-runner.runDevRunnerWithInput")(function* ( + input: DevRunnerCliInput, +) { + const { portOffset, devInstance } = yield* OffsetConfig.pipe( + Effect.mapError( + (cause) => + new DevRunnerConfigurationError({ + configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"], + cause, + }), + ), + ); - if ( - (input.mode === "dev" || input.mode === "dev:web") && - input.host !== undefined && - !isProxiableBindHost(input.host) - ) { - return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); - } + if ( + (input.mode === "dev" || input.mode === "dev:web") && + input.host !== undefined && + !isProxiableBindHost(input.host) + ) { + return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); + } - const worktreePath = yield* resolveGitWorktreePath(yield* HostProcess.WorkingDirectory); + const worktreePath = yield* resolveGitWorktreePath(yield* HostProcess.WorkingDirectory); - const { offset, source } = yield* resolveOffset({ - portOffset, - devInstance, - worktreePath, - }); + const { offset, source } = yield* resolveOffset({ + portOffset, + devInstance, + worktreePath, + }); - const { serverOffset, webOffset } = yield* resolveModePortOffsets({ - mode: input.mode, - startOffset: offset, - hasExplicitServerPort: input.port !== undefined, - hasExplicitDevUrl: input.devUrl !== undefined, - checkPortAvailability: makeDefaultCheckPortAvailability(input.host), - }); + const { serverOffset, webOffset } = yield* resolveModePortOffsets({ + mode: input.mode, + startOffset: offset, + hasExplicitServerPort: input.port !== undefined, + hasExplicitDevUrl: input.devUrl !== undefined, + checkPortAvailability: makeDefaultCheckPortAvailability(input.host), + }); - const hostEnvironment = yield* HostProcess.Environment; - // A dev server started inside a worktree defaults to that worktree's own - // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must - // outrank an ambient T3CODE_HOME. `--home-dir` still wins. - const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory); - // Trim before choosing: `--home-dir ""` is not a selection, and treating it - // as one would skip the worktree default and land on the shared home — - // exactly the outcome this precedence exists to prevent. - const resolvedT3Home = - (input.t3Home?.trim() || undefined) ?? - worktreeHome ?? - (hostEnvironment.T3CODE_HOME?.trim() || undefined); - const env = yield* createDevRunnerEnv({ - mode: input.mode, - baseEnv: hostEnvironment, - serverOffset, - webOffset, - t3Home: resolvedT3Home, - browser: input.browser, - autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, - logWebSocketEvents: input.logWebSocketEvents, - host: input.host, - port: input.port, - devUrl: input.devUrl, - }); + const hostEnvironment = yield* HostProcess.Environment; + // A dev server started inside a worktree defaults to that worktree's own + // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must + // outrank an ambient T3CODE_HOME. `--home-dir` still wins. + const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory); + // Trim before choosing: `--home-dir ""` is not a selection, and treating it + // as one would skip the worktree default and land on the shared home — + // exactly the outcome this precedence exists to prevent. + const resolvedT3Home = + (input.t3Home?.trim() || undefined) ?? + worktreeHome ?? + (hostEnvironment.T3CODE_HOME?.trim() || undefined); + const env = yield* createDevRunnerEnv({ + mode: input.mode, + baseEnv: hostEnvironment, + serverOffset, + webOffset, + t3Home: resolvedT3Home, + browser: input.browser, + autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, + logWebSocketEvents: input.logWebSocketEvents, + host: input.host, + port: input.port, + devUrl: input.devUrl, + }); - const selectionSuffix = - serverOffset !== offset || webOffset !== offset - ? ` selectedOffset(server=${serverOffset},web=${webOffset})` - : ""; - const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); - - yield* Effect.logInfo( - `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, - ); - - // Before the share block: --dry-run only resolves and prints. Sharing would - // replace, then tear down, whatever mapping the port already had — a - // surprising side effect from a command documented as inert. - if (input.dryRun) { - return; - } + const selectionSuffix = + serverOffset !== offset || webOffset !== offset + ? ` selectedOffset(server=${serverOffset},web=${webOffset})` + : ""; + const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); + + yield* Effect.logInfo( + `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, + ); - const sharedWebPort = BASE_WEB_PORT + webOffset; - if (input.share) { - if (input.mode === "dev:server") { - yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); - } else if (input.mode === "dev:desktop") { - yield* Effect.logWarning( - "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", - ); - } else { - // acquireRelease, not share-then-addFinalizer: the mapping outlives this - // process (and reboots), so the cleanup has to be registered atomically - // with creating it. An interrupt landing in between would otherwise - // leave a mapping pointing at a port nothing is listening on. - const shared = yield* Effect.acquireRelease( - shareDevServer({ webPort: sharedWebPort }), - () => - unshareDevServer(sharedWebPort).pipe( - Effect.flatMap((result) => - result.cleared - ? Effect.void - : Effect.logWarning( - `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${ - result.explanation ? `: ${result.explanation}` : "" - }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`, - ), - ), - ), - ).pipe( - Effect.tapError((error: DevShareError) => - Effect.logWarning( - `[dev-runner] could not share on the tailnet: ${error.message}${ - error.hint ? ` — ${error.hint}` : "" - }`, - ), + // Before the share block: --dry-run only resolves and prints. Sharing would + // replace, then tear down, whatever mapping the port already had — a + // surprising side effect from a command documented as inert. + if (input.dryRun) { + return; + } + + const sharedWebPort = BASE_WEB_PORT + webOffset; + if (input.share) { + if (input.mode === "dev:server") { + yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); + } else if (input.mode === "dev:desktop") { + yield* Effect.logWarning( + "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", + ); + } else { + // acquireRelease, not share-then-addFinalizer: the mapping outlives this + // process (and reboots), so the cleanup has to be registered atomically + // with creating it. An interrupt landing in between would otherwise + // leave a mapping pointing at a port nothing is listening on. + const shared = yield* Effect.acquireRelease(shareDevServer({ webPort: sharedWebPort }), () => + unshareDevServer(sharedWebPort).pipe( + Effect.flatMap((result) => + result.cleared + ? Effect.void + : Effect.logWarning( + `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${ + result.explanation ? `: ${result.explanation}` : "" + }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`, + ), + ), + ), + ).pipe( + Effect.tapError((error: DevShareError) => + Effect.logWarning( + `[dev-runner] could not share on the tailnet: ${error.message}${ + error.hint ? ` — ${error.hint}` : "" + }`, ), - Effect.option, - Effect.map(Option.getOrUndefined), - ); - - if (shared) { - // The app is reached from the tailnet origin. Vite already allows - // *.ts.net hosts; the backend needs the origin for credentialed - // requests that bypass the proxy (desktop renderer, direct calls). - env.T3CODE_DEV_ALLOWED_ORIGINS = [ - env.T3CODE_DEV_ALLOWED_ORIGINS, - new URL(shared.url).origin, - ] - .filter((entry) => entry && entry.length > 0) - .join(","); - if (input.devUrl === undefined) { - env.VITE_DEV_SERVER_URL = shared.url; - } - // A shared origin serves a remote browser, where unbundled dev's - // per-module requests each pay a tailnet round trip — a cold module - // graph takes minutes to first paint. Bundled dev collapses that to a - // few chunk requests. - if (env.T3CODE_BUNDLED_DEV === undefined) { - env.T3CODE_BUNDLED_DEV = "1"; - } - yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); + ), + Effect.option, + Effect.map(Option.getOrUndefined), + ); + + if (shared) { + // The app is reached from the tailnet origin. Vite already allows + // *.ts.net hosts; the backend needs the origin for credentialed + // requests that bypass the proxy (desktop renderer, direct calls). + env.T3CODE_DEV_ALLOWED_ORIGINS = [ + env.T3CODE_DEV_ALLOWED_ORIGINS, + new URL(shared.url).origin, + ] + .filter((entry) => entry && entry.length > 0) + .join(","); + if (input.devUrl === undefined) { + env.VITE_DEV_SERVER_URL = shared.url; } + // A shared origin serves a remote browser, where unbundled dev's + // per-module requests each pay a tailnet round trip — a cold module + // graph takes minutes to first paint. Bundled dev collapses that to a + // few chunk requests. + if (env.T3CODE_BUNDLED_DEV === undefined) { + env.T3CODE_BUNDLED_DEV = "1"; + } + yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); } } + } - const args = [...MODE_ARGS[input.mode], ...input.runArgs]; - const spawnCommand = yield* resolveSpawnCommand("vp", args, { env }); - const processContext = { - mode: input.mode, - executable: "vp" as const, - argumentCount: args.length, - shell: spawnCommand.shell, - } as const; - const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - env, - extendEnv: false, - shell: spawnCommand.shell, - // Keep Vite+ in the same process group so terminal signals (Ctrl+C) - // reach it directly. Effect defaults to detached: true on non-Windows, - // which would put the runner in a new group and require manual forwarding. - detached: false, - forceKillAfter: "1500 millis", - }).pipe( - Effect.mapError( - (cause) => - new DevRunnerProcessError({ - ...processContext, - operation: "spawn", - cause, - }), - ), - ); - - const exitCode = yield* child.exitCode.pipe( - Effect.mapError( - (cause) => - new DevRunnerProcessError({ - ...processContext, - operation: "wait-for-exit", - cause, - }), - ), - ); - if (exitCode !== 0) { - return yield* new DevRunnerProcessExitError({ - ...processContext, - exitCode, - }); - } - }); -} + const args = [...MODE_ARGS[input.mode], ...input.runArgs]; + const spawnCommand = yield* resolveSpawnCommand("vp", args, { env }); + const processContext = { + mode: input.mode, + executable: "vp" as const, + argumentCount: args.length, + shell: spawnCommand.shell, + } as const; + const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env, + extendEnv: false, + shell: spawnCommand.shell, + // Keep Vite+ in the same process group so terminal signals (Ctrl+C) + // reach it directly. Effect defaults to detached: true on non-Windows, + // which would put the runner in a new group and require manual forwarding. + detached: false, + forceKillAfter: "1500 millis", + }).pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "spawn", + cause, + }), + ), + ); + + const exitCode = yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "wait-for-exit", + cause, + }), + ), + ); + if (exitCode !== 0) { + return yield* new DevRunnerProcessExitError({ + ...processContext, + exitCode, + }); + } +}); const devRunnerCli = Command.make("dev-runner", { mode: Argument.Literals("mode", DEV_RUNNER_MODES).pipe( diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index 7db1bd20031d..e16a0af464e6 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -96,40 +96,39 @@ export const hashBundle = Effect.fn("hashBundle")(function* (root: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const entries: string[] = []; - const visit = ( + const visit = Effect.fn("mobile-native-client.visit")(function* ( relative: string, - ): Effect.Effect => - Effect.gen(function* () { - const names = (yield* fs.readDirectory(path.join(root, relative))).sort((a, b) => - a.localeCompare(b, "en"), + ): Effect.fn.Return { + const names = (yield* fs.readDirectory(path.join(root, relative))).sort((a, b) => + a.localeCompare(b, "en"), + ); + for (const name of names) { + const key = path.join(relative, name); + const absolute = path.join(root, key); + // FileSystem.stat follows links; readLink distinguishes them without following a cycle. + const link = yield* fs.readLink(absolute).pipe( + Effect.catchIf( + (error) => isNotLink(error.reason.cause), + () => Effect.succeed(null), + ), ); - for (const name of names) { - const key = path.join(relative, name); - const absolute = path.join(root, key); - // FileSystem.stat follows links; readLink distinguishes them without following a cycle. - const link = yield* fs.readLink(absolute).pipe( - Effect.catchIf( - (error) => isNotLink(error.reason.cause), - () => Effect.succeed(null), - ), + if (link !== null) { + entries.push(`link:${key}:${link}`); + continue; + } + const info = yield* fs.stat(absolute); + if (info.type === "Directory") { + entries.push(`directory:${key}`); + yield* visit(key); + } else { + const chunks = yield* fs.stream(absolute, { chunkSize: 65536 }).pipe( + Stream.mapEffect((chunk) => digest(chunk)), + Stream.runCollect, ); - if (link !== null) { - entries.push(`link:${key}:${link}`); - continue; - } - const info = yield* fs.stat(absolute); - if (info.type === "Directory") { - entries.push(`directory:${key}`); - yield* visit(key); - } else { - const chunks = yield* fs.stream(absolute, { chunkSize: 65536 }).pipe( - Stream.mapEffect((chunk) => digest(chunk)), - Stream.runCollect, - ); - entries.push(`file:${key}:${yield* digest(chunks.join("\n"))}`); - } + entries.push(`file:${key}:${yield* digest(chunks.join("\n"))}`); } - }); + } + }); yield* visit(""); return yield* digest(entries.map((entry) => `${entry.length}:${entry}`).join("")); }); diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index 52b9a84078e7..06d0b470a956 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -170,62 +170,60 @@ export const runCommand = Effect.fn("runCommand")(function* ( } }); -export function collectSources( +export const collectSources = Effect.fn("mobile-native-static-check.collectSources")(function* ( directory: string, root: string, -): Effect.Effect< +): Effect.fn.Return< ReadonlyArray, NativeStaticCheckSourceDiscoveryError, FileSystem.FileSystem | Path.Path > { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const entries = yield* fs.readDirectory(directory).pipe( + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.mapError( + (cause) => + new NativeStaticCheckSourceDiscoveryError({ + operation: "read-directory", + path: directory, + cause, + }), + ), + ); + const sources: Array = []; + + for (const entry of entries) { + const entryPath = path.join(directory, entry); + const stat = yield* fs.stat(entryPath).pipe( Effect.mapError( (cause) => new NativeStaticCheckSourceDiscoveryError({ - operation: "read-directory", - path: directory, + operation: "stat-entry", + path: entryPath, cause, }), ), ); - const sources: Array = []; - - for (const entry of entries) { - const entryPath = path.join(directory, entry); - const stat = yield* fs.stat(entryPath).pipe( - Effect.mapError( - (cause) => - new NativeStaticCheckSourceDiscoveryError({ - operation: "stat-entry", - path: entryPath, - cause, - }), - ), - ); - - if (stat.type === "Directory") { - const isGeneratedNativeProjectDirectory = - directory === root && generatedNativeProjectDirectories.has(entry); - if (excludedDirectories.has(entry) || isGeneratedNativeProjectDirectory) { - continue; - } + if (stat.type === "Directory") { + const isGeneratedNativeProjectDirectory = + directory === root && generatedNativeProjectDirectories.has(entry); - sources.push(...(yield* collectSources(entryPath, root))); + if (excludedDirectories.has(entry) || isGeneratedNativeProjectDirectory) { continue; } - if (stat.type === "File" && sourceExtensions.has(path.extname(entry))) { - sources.push(entryPath); - } + sources.push(...(yield* collectSources(entryPath, root))); + continue; } - return sources; - }); -} + if (stat.type === "File" && sourceExtensions.has(path.extname(entry))) { + sources.push(entryPath); + } + } + + return sources; +}); const runNativeStaticChecks = Effect.fn("runNativeStaticChecks")(function* () { const path = yield* Path.Path; diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index 687e0d7513c7..ceb32f8f35ca 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -32,32 +32,32 @@ const resolveMockUpdateServerConfig = Effect.gen(function* () { } satisfies MockUpdateServerConfig; }); -const isOutsideRoot = (rootRealPath: string, filePath: string) => - Effect.gen(function* () { - const path = yield* Path.Path; - const relativePath = path.relative(rootRealPath, filePath); - return ( - relativePath === ".." || relativePath.startsWith("../") || relativePath.startsWith("..\\") - ); - }); +const isOutsideRoot = Effect.fn("mock-update-server.isOutsideRoot")(function* ( + rootRealPath: string, + filePath: string, +) { + const path = yield* Path.Path; + const relativePath = path.relative(rootRealPath, filePath); + return relativePath === ".." || relativePath.startsWith("../") || relativePath.startsWith("..\\"); +}); -const isWithinRoot = (rootRealPath: string, filePath: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const resolvedFilePath = yield* fileSystem.realPath(filePath).pipe( - Effect.match({ - onFailure: () => undefined, - onSuccess: (resolvedPath) => resolvedPath, - }), - ); +const isWithinRoot = Effect.fn("mock-update-server.isWithinRoot")(function* ( + rootRealPath: string, + filePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const resolvedFilePath = yield* fileSystem.realPath(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (resolvedPath) => resolvedPath, + }), + ); - return ( - resolvedFilePath !== undefined && !(yield* isOutsideRoot(rootRealPath, resolvedFilePath)) - ); - }); + return resolvedFilePath !== undefined && !(yield* isOutsideRoot(rootRealPath, resolvedFilePath)); +}); -const resolveRequestedFilePath = (rootRealPath: string, requestUrl: string | undefined) => - Effect.gen(function* () { +const resolveRequestedFilePath = Effect.fn("mock-update-server.resolveRequestedFilePath")( + function* (rootRealPath: string, requestUrl: string | undefined) { const path = yield* Path.Path; const rawPath = (requestUrl ?? "/").split("?", 1)[0] ?? "/"; const decodedPath = yield* Effect.try({ @@ -84,24 +84,27 @@ const resolveRequestedFilePath = (rootRealPath: string, requestUrl: string | und ); return (yield* isOutsideRoot(rootRealPath, filePath)) ? undefined : filePath; - }); + }, +); -const isServableFile = (rootRealPath: string, filePath: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const stat = yield* fileSystem.stat(filePath).pipe( - Effect.match({ - onFailure: () => undefined, - onSuccess: (info) => info, - }), - ); +const isServableFile = Effect.fn("mock-update-server.isServableFile")(function* ( + rootRealPath: string, + filePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const stat = yield* fileSystem.stat(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (info) => info, + }), + ); - if (stat?.type !== "File") { - return false; - } + if (stat?.type !== "File") { + return false; + } - return yield* isWithinRoot(rootRealPath, filePath); - }); + return yield* isWithinRoot(rootRealPath, filePath); +}); export const makeMockUpdateRouteLayer = (rootRealPath: string) => { return HttpRouter.add( diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 58e6c3dcccf1..6b53e031f9eb 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -192,44 +192,39 @@ const parseNightlyTag = ( }; }; -export const resolvePreviousReleaseTag = ( - channel: ReleaseChannel, - currentTag: string, - tags: ReadonlyArray, -) => - Effect.gen(function* () { - if (channel === "stable") { - const current = parseStableTag(currentTag); - if (!current) { - return yield* new InvalidReleaseTagError({ channel, currentTag }); - } - - const candidates = tags - .map((tag) => ({ tag, parsed: parseStableTag(tag) })) - .filter( - (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, - ) - .filter((entry) => compareStableVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); - - return candidates[0]?.tag; - } - - const current = parseNightlyTag(currentTag, channel); +export const resolvePreviousReleaseTag = Effect.fn( + "resolve-previous-release-tag.resolvePreviousReleaseTag", +)(function* (channel: ReleaseChannel, currentTag: string, tags: ReadonlyArray) { + if (channel === "stable") { + const current = parseStableTag(currentTag); if (!current) { return yield* new InvalidReleaseTagError({ channel, currentTag }); } const candidates = tags - .map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) })) + .map((tag) => ({ tag, parsed: parseStableTag(tag) })) .filter( - (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined, + (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, ) - .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); + .filter((entry) => compareStableVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); return candidates[0]?.tag; - }); + } + + const current = parseNightlyTag(currentTag, channel); + if (!current) { + return yield* new InvalidReleaseTagError({ channel, currentTag }); + } + + const candidates = tags + .map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) })) + .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined) + .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); + + return candidates[0]?.tag; +}); const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( From 669445a7705c36be7eb7a5f4f02af0db41ccc264 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:31:02 -0700 Subject: [PATCH 011/458] refactor(client): use Effect functions across web desktop and mobile (#21998) --- apps/desktop/src/app/DesktopCliCommand.ts | 8 +- .../src/app/DesktopLegacyLocalStorage.ts | 8 +- .../backend/DesktopBackendConfiguration.ts | 18 +- .../src/backend/DesktopBackendManager.ts | 27 +- .../desktop/src/backend/DesktopBackendPool.ts | 98 ++- apps/desktop/src/electron/ElectronWindow.ts | 5 +- apps/desktop/src/preview/Manager.ts | 8 +- apps/desktop/src/snapShot/DesktopSnapShot.ts | 15 +- .../telemetry/DesktopTelemetryPublisher.ts | 178 ++--- .../src/updates/DesktopRemoteUpdates.ts | 137 ++-- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 335 ++++---- apps/desktop/src/wsl/DesktopWslServerTree.ts | 29 +- .../agent-awareness/remoteRegistration.ts | 730 +++++++++--------- apps/mobile/src/features/cloud/dpop.ts | 116 ++- .../src/features/cloud/linkEnvironment.ts | 160 ++-- apps/web/src/cloud/dpop.ts | 60 +- apps/web/src/cloud/linkEnvironment.ts | 276 +++---- apps/web/src/connection/storage.ts | 64 +- .../src/platform/shellCacheEncoding.ts | 10 +- .../src/relay/managedRelayState.ts | 43 +- .../src/state/environmentHttpAuth.ts | 17 +- .../client-runtime/src/state/fileMetadata.ts | 17 +- .../client-runtime/src/state/orchestration.ts | 37 +- .../src/state/pullRequestStatsPersistence.ts | 16 +- .../client-runtime/src/state/pullRequests.ts | 115 +-- .../src/state/queryPersistence.ts | 29 +- packages/client-runtime/src/state/runtime.ts | 18 +- .../src/state/threadCommands.ts | 35 +- 28 files changed, 1310 insertions(+), 1299 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index aebe8adb4a05..294f8a8b984e 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -83,15 +83,17 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - const isOurLink = (link: string) => - Effect.gen(function* () { + const isOurLink = Effect.fn("DesktopCliCommand.isOurLink")( + function* (link: string) { yield* fs.readLink(link); // The launcher is a few KB; never read a large binary another `t3` links to. const info = yield* fs.stat(link); if (info.type !== "File" || Number(info.size) > 16_384) return false; const content = yield* fs.readFileString(link); return content.includes(DesktopCliShim.MARKER); - }).pipe(Effect.orElseSucceed(() => false)); + }, + Effect.orElseSucceed(() => false), + ); const powershell = (env: Record) => Effect.scoped( diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index 9deba2389922..122aa3393f9c 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -34,8 +34,8 @@ const make = Effect.gen(function* () { const markerPath = yield* Ref.make(Option.none()); /** Newest file mtime in a directory; appending to a log leaves the directory's own mtime alone. */ - const newestFileMtime = (directory: string) => - Effect.gen(function* () { + const newestFileMtime = Effect.fn("DesktopLegacyLocalStorage.newestFileMtime")( + function* (directory: string) { let newest = 0; for (const name of yield* fs.readDirectory(directory)) { const info = yield* fs.stat(path.join(directory, name)).pipe(Effect.option); @@ -47,7 +47,9 @@ const make = Effect.gen(function* () { newest = Math.max(newest, mtime); } return newest; - }).pipe(Effect.option); + }, + (effect, _directory) => effect.pipe(Effect.option), + ); const findV1LocalStorage = Effect.gen(function* () { let newest: { readonly directory: string; readonly mtime: number } | null = null; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 77a90e0512aa..dadbe8acc743 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -875,8 +875,9 @@ export const make = Effect.gen(function* () { } return distro ? `WSL (${distro})` : "WSL"; }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")), - resolveWsl: (input) => - Effect.gen(function* () { + // The span options depend on each request's port and distro. + resolveWsl: Effect.fnUntraced( + function* (input) { const shared = yield* sharedInputs; return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), @@ -884,11 +885,14 @@ export const make = Effect.gen(function* () { Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); - }).pipe( - Effect.withSpan("desktop.backendConfiguration.resolveWsl", { - attributes: { port: input.port, distro: input.distro ?? null }, - }), - ), + }, + (effect, input) => + effect.pipe( + Effect.withSpan("desktop.backendConfiguration.resolveWsl", { + attributes: { port: input.port, distro: input.distro ?? null }, + }), + ), + ), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 5ad864f945d7..e74e98a079b9 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1114,20 +1114,25 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); - const waitForReady = (timeout: Duration.Duration): Effect.Effect => - Effect.gen(function* () { + const waitForReady: (timeout: Duration.Duration) => Effect.Effect = Effect.fn( + "DesktopBackendManager.waitForReady", + )( + function* (_timeout: Duration.Duration) { const current = yield* Ref.get(state); if (!current.desiredRunning) return { done: true, ready: false }; return current.ready ? { done: true, ready: true } : { done: false, ready: false }; - }).pipe( - Effect.repeat({ - until: (status) => status.done, - schedule: Schedule.spaced(Duration.millis(100)), - }), - Effect.map((status) => status.ready), - Effect.timeoutOption(timeout), - Effect.map(Option.getOrElse(() => false)), - ); + }, + (effect, timeout) => + effect.pipe( + Effect.repeat({ + until: (status) => status.done, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.map((status) => status.ready), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ), + ); yield* Effect.addFinalizer(() => stop()); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index a0e6d4377f2c..0ee88b04667f 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -249,61 +249,59 @@ export const layer = Layer.effect( ), ); - const unregister: DesktopBackendPool["Service"]["unregister"] = (id) => - Effect.gen(function* () { - if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { - return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); - } - const done = yield* Deferred.make(); - const action = yield* SynchronizedRef.modifyEffect( - instancesRef, - ( - current, - ): Effect.Effect< - readonly [UnregisterAction, ReadonlyMap] - > => { - const entry = current.get(id); - if (entry === undefined) { - return Effect.succeed([{ _tag: "Absent" } as const, current] as const); - } - if (entry._tag === "Closing") { - return Effect.succeed([ - { _tag: "Wait", done: entry.done } as const, - current, - ] as const); - } - const next = new Map(current); - next.set(id, { _tag: "Closing", done }); - return Effect.succeed([ - { _tag: "Close", entry } as const, - next as ReadonlyMap, - ] as const); - }, - ); - - if (action._tag === "Absent") return; - if (action._tag === "Wait") { - yield* Deferred.await(action.done); - return; - } - - const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { - const closing = current.get(id); - if (closing?._tag !== "Closing" || closing.done !== done) { - return Effect.succeed([undefined, current] as const); + const unregister: DesktopBackendPool["Service"]["unregister"] = Effect.fn( + "DesktopBackendPool.unregister", + )(function* (id) { + if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { + return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); + } + const done = yield* Deferred.make(); + const action = yield* SynchronizedRef.modifyEffect( + instancesRef, + ( + current, + ): Effect.Effect< + readonly [UnregisterAction, ReadonlyMap] + > => { + const entry = current.get(id); + if (entry === undefined) { + return Effect.succeed([{ _tag: "Absent" } as const, current] as const); + } + if (entry._tag === "Closing") { + return Effect.succeed([{ _tag: "Wait", done: entry.done } as const, current] as const); } const next = new Map(current); - next.delete(id); + next.set(id, { _tag: "Closing", done }); return Effect.succeed([ - undefined, + { _tag: "Close", entry } as const, next as ReadonlyMap, ] as const); - }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); - yield* Option.match(action.entry.scope, { - onNone: () => Effect.void, - onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), - }).pipe(Effect.ensuring(finish)); - }); + }, + ); + + if (action._tag === "Absent") return; + if (action._tag === "Wait") { + yield* Deferred.await(action.done); + return; + } + + const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { + const closing = current.get(id); + if (closing?._tag !== "Closing" || closing.done !== done) { + return Effect.succeed([undefined, current] as const); + } + const next = new Map(current); + next.delete(id); + return Effect.succeed([ + undefined, + next as ReadonlyMap, + ] as const); + }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); + yield* Option.match(action.entry.scope, { + onNone: () => Effect.void, + onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), + }).pipe(Effect.ensuring(finish)); + }); return DesktopBackendPool.of({ get: (id) => diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index a7ab05303f72..993e930678ed 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -324,8 +324,9 @@ export const make = Effect.gen(function* () { cause, }), }).pipe(Effect.orDie), - sendAll: (channel, ...args) => - Effect.gen(function* () { + sendAll: + /* Window broadcasts run for streamed updates; avoid a span per broadcast. */ + Effect.fnUntraced(function* (channel, ...args) { for (const window of yield* listWindows) { if (yield* isWindowDestroyed(window)) { continue; diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 7a1ff6dff1d0..b036a67ac8ba 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2375,8 +2375,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // The scheme is read after the session attaches so a concurrent setColorScheme is not // overwritten with a stale snapshot. - const restoreControlSession = (tabId: string, wc: Electron.WebContents) => - Effect.gen(function* () { + const restoreControlSession = Effect.fn("PreviewManager.restoreControlSession")( + function* (tabId: string, wc: Electron.WebContents) { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; const control = yield* ensureControlSession(wc); @@ -2401,7 +2401,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); } - }).pipe(Effect.ignore); + }, + (effect, _tabId, _wc) => effect.pipe(Effect.ignore), + ); // Only the session that saw the detach is replaced: by the time the event is // handled the guest may already hold a newer one, which must stay. diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 539d9019bd52..569bf732df65 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1708,8 +1708,8 @@ export const make = Effect.gen(function* () { ), Effect.mapError((cause) => new DesktopSnapShotError({ operation: "list-pending", cause })), ), - read: (id) => - Effect.gen(function* () { + read: Effect.fn("DesktopSnapShot.read")( + function* (id) { const metadata = yield* fileSystem .readFileString(path.join(captureDirectory, `${id}.json`)) .pipe(Effect.flatMap(decodePendingCaptureJson)); @@ -1718,11 +1718,14 @@ export const make = Effect.gen(function* () { ...metadata, dataUrl: `data:image/png;base64,${Base64.encode(png)}`, }; - }).pipe( - Effect.mapError( - (cause) => new DesktopSnapShotError({ operation: "read", captureId: id, cause }), + }, + (effect, id) => + effect.pipe( + Effect.mapError( + (cause) => new DesktopSnapShotError({ operation: "read", captureId: id, cause }), + ), ), - ), + ), setAnimationDestination: (id, destination) => Effect.promise(async () => { if (linuxFeedback?.id === id && destination.relativeFrame) { diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 1155a2b15b0c..5e4b1de81b83 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -199,96 +199,98 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { ), ).pipe(Effect.forkScoped); - const sampleOnce = (allowSuspendRecovery: boolean) => - Effect.gen(function* () { - const sampledAt = yield* DateTime.now; - const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt); - const demand = (yield* Ref.get(diagnosticsDemandSources)).size > 0; - const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all( - [ - Ref.get(powerState), - powerMonitor.getSystemIdleTime, - powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), - powerMonitor.isOnBatteryPower, - electronApp.getAppMetrics, - ], - { concurrency: "unbounded" }, - ); - // One Electron sampler preserves the shared CPU measurement interval. - yield* rendererHistory.recordMetrics(metrics); - const polledLocked = - systemIdleState === "unknown" - ? currentPower.locked - : booleanState(systemIdleState === "locked"); - const polledOnBattery = booleanState(onBattery); - const observedPower = yield* Ref.modify(powerState, (latestPower) => { - const preserveEventLocked = - latestPower.lockedEventPending || - latestPower.locked !== currentPower.locked || - latestPower.lockedEventPending !== currentPower.lockedEventPending; - const preserveEventOnBattery = - latestPower.onBatteryEventPending || - latestPower.onBattery !== currentPower.onBattery || - latestPower.onBatteryEventPending !== currentPower.onBatteryEventPending; - const preserveEventSuspended = - latestPower.suspendedEventPending || - latestPower.suspended !== currentPower.suspended || - latestPower.suspendedEventPending !== currentPower.suspendedEventPending; - const next: PowerState = { - ...latestPower, - idle: idleState(systemIdleState), - locked: preserveEventLocked ? latestPower.locked : polledLocked, - lockedEventPending: - latestPower.lockedEventPending && - (systemIdleState === "unknown" || polledLocked !== latestPower.locked), - suspended: - allowSuspendRecovery && !preserveEventSuspended ? false : latestPower.suspended, - suspendedEventPending: false, - onBattery: preserveEventOnBattery ? latestPower.onBattery : polledOnBattery, - onBatteryEventPending: - latestPower.onBatteryEventPending && polledOnBattery !== latestPower.onBattery, + const sampleOnce = + /* Periodic telemetry sampling should not generate its own tracing traffic. */ + Effect.fnUntraced( + function* (allowSuspendRecovery: boolean) { + const sampledAt = yield* DateTime.now; + const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt); + const demand = (yield* Ref.get(diagnosticsDemandSources)).size > 0; + const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all( + [ + Ref.get(powerState), + powerMonitor.getSystemIdleTime, + powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS), + powerMonitor.isOnBatteryPower, + electronApp.getAppMetrics, + ], + { concurrency: "unbounded" }, + ); + // One Electron sampler preserves the shared CPU measurement interval. + yield* rendererHistory.recordMetrics(metrics); + const polledLocked = + systemIdleState === "unknown" + ? currentPower.locked + : booleanState(systemIdleState === "locked"); + const polledOnBattery = booleanState(onBattery); + const observedPower = yield* Ref.modify(powerState, (latestPower) => { + const preserveEventLocked = + latestPower.lockedEventPending || + latestPower.locked !== currentPower.locked || + latestPower.lockedEventPending !== currentPower.lockedEventPending; + const preserveEventOnBattery = + latestPower.onBatteryEventPending || + latestPower.onBattery !== currentPower.onBattery || + latestPower.onBatteryEventPending !== currentPower.onBatteryEventPending; + const preserveEventSuspended = + latestPower.suspendedEventPending || + latestPower.suspended !== currentPower.suspended || + latestPower.suspendedEventPending !== currentPower.suspendedEventPending; + const next: PowerState = { + ...latestPower, + idle: idleState(systemIdleState), + locked: preserveEventLocked ? latestPower.locked : polledLocked, + lockedEventPending: + latestPower.lockedEventPending && + (systemIdleState === "unknown" || polledLocked !== latestPower.locked), + suspended: + allowSuspendRecovery && !preserveEventSuspended ? false : latestPower.suspended, + suspendedEventPending: false, + onBattery: preserveEventOnBattery ? latestPower.onBattery : polledOnBattery, + onBatteryEventPending: + latestPower.onBatteryEventPending && polledOnBattery !== latestPower.onBattery, + }; + return [next, next] as const; + }); + const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]); + const snapshot: DesktopHostTelemetrySnapshot = { + version: 1, + type: "desktopTelemetry", + sequence: nextSequence, + sampledAtUnixMs, + electronPid: process.pid, + power: { + source: "electron-main", + idle: observedPower.idle, + idleSeconds, + locked: observedPower.locked, + suspended: observedPower.suspended, + onBattery: observedPower.onBattery, + lowPowerMode: "unknown", + thermalState: observedPower.thermalState, + stale: false, + updatedAt: sampledAt, + }, + speedLimitPercent: observedPower.speedLimitPercent, + electronProcesses: (demand ? metrics : []).map((metric) => ({ + pid: metric.pid, + creationTimeMs: Math.max(0, Math.round(metric.creationTime)), + type: metric.type, + ...(metric.name === undefined ? {} : { name: metric.name }), + ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }), + cpuPercent: metric.cpu.percentCPUUsage, + ...(metric.cpu.cumulativeCPUUsage === undefined + ? {} + : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }), + idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond, + workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)), + peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)), + })), }; - return [next, next] as const; - }); - const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]); - const snapshot: DesktopHostTelemetrySnapshot = { - version: 1, - type: "desktopTelemetry", - sequence: nextSequence, - sampledAtUnixMs, - electronPid: process.pid, - power: { - source: "electron-main", - idle: observedPower.idle, - idleSeconds, - locked: observedPower.locked, - suspended: observedPower.suspended, - onBattery: observedPower.onBattery, - lowPowerMode: "unknown", - thermalState: observedPower.thermalState, - stale: false, - updatedAt: sampledAt, - }, - speedLimitPercent: observedPower.speedLimitPercent, - electronProcesses: (demand ? metrics : []).map((metric) => ({ - pid: metric.pid, - creationTimeMs: Math.max(0, Math.round(metric.creationTime)), - type: metric.type, - ...(metric.name === undefined ? {} : { name: metric.name }), - ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }), - cpuPercent: metric.cpu.percentCPUUsage, - ...(metric.cpu.cumulativeCPUUsage === undefined - ? {} - : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }), - idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond, - workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)), - peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)), - })), - }; - yield* Ref.set(latest, Option.some(snapshot)); - yield* PubSub.publish(changes, snapshot); - }).pipe( + yield* Ref.set(latest, Option.some(snapshot)); + yield* PubSub.publish(changes, snapshot); + }, Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), (cause) => diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index 716a73c199aa..cb1b86c07754 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -188,84 +188,85 @@ export const listen: Effect.Effect< Effect.forkScoped, ); - const step = (state: DesktopUpdateState): Effect.Effect => - Effect.gen(function* () { - const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); - switch (next.action) { - case "wait": - return false; - case "check": - attempts = { ...attempts, checks: attempts.checks + 1 }; - yield* updates.check("remote-update").pipe( - Effect.flatMap((result) => { - if (result.checked) return Effect.void; - attempts = { ...attempts, checks: attempts.checks - 1 }; - return retryLater(state); - }), - Effect.forkScoped, - ); + const step = Effect.fn("DesktopRemoteUpdates.step")(function* ( + state: DesktopUpdateState, + ): Effect.fn.Return { + const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); + switch (next.action) { + case "wait": + return false; + case "check": + attempts = { ...attempts, checks: attempts.checks + 1 }; + yield* updates.check("remote-update").pipe( + Effect.flatMap((result) => { + if (result.checked) return Effect.void; + attempts = { ...attempts, checks: attempts.checks - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "download": + attempts = { ...attempts, downloads: attempts.downloads + 1 }; + yield* updates.download.pipe( + Effect.flatMap((result) => { + if (result.accepted) return Effect.void; + attempts = { ...attempts, downloads: attempts.downloads - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "install": { + // The download event fires before its action releases the + // updater reservation. Wait until the prepared install can + // be committed by the client in a separate RPC. + if (yield* updates.isActionActive) { + yield* retryLater(state); return false; - case "download": - attempts = { ...attempts, downloads: attempts.downloads + 1 }; - yield* updates.download.pipe( - Effect.flatMap((result) => { - if (result.accepted) return Effect.void; - attempts = { ...attempts, downloads: attempts.downloads - 1 }; - return retryLater(state); - }), - Effect.forkScoped, - ); - return false; - case "install": { - // The download event fires before its action releases the - // updater reservation. Wait until the prepared install can - // be committed by the client in a separate RPC. - if (yield* updates.isActionActive) { - yield* retryLater(state); - return false; - } - if (state.downloadedVersion === null) { - yield* publishReport( - state, - { - outcome: "failed", - reason: "The desktop app lost the downloaded update.", - }, - request.requestId, - ); - return true; - } - yield* Ref.set( - preparedUpdateRef, - Option.some({ - requestId: request.requestId, - downloadedVersion: state.downloadedVersion, - status: "prepared", - expiresAt: - (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL), - }), - ); - yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId); - yield* logInfo("remote update prepared", { requestId: request.requestId }); - return true; } - case "done": + if (state.downloadedVersion === null) { yield* publishReport( state, { - outcome: next.outcome, - ...(next.reason === undefined ? {} : { reason: next.reason }), + outcome: "failed", + reason: "The desktop app lost the downloaded update.", }, request.requestId, ); - yield* logInfo("remote update finished", { - requestId: request.requestId, - outcome: next.outcome, - reason: next.reason ?? null, - }); return true; + } + yield* Ref.set( + preparedUpdateRef, + Option.some({ + requestId: request.requestId, + downloadedVersion: state.downloadedVersion, + status: "prepared", + expiresAt: + (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL), + }), + ); + yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId); + yield* logInfo("remote update prepared", { requestId: request.requestId }); + return true; } - }); + case "done": + yield* publishReport( + state, + { + outcome: next.outcome, + ...(next.reason === undefined ? {} : { reason: next.reason }), + }, + request.requestId, + ); + yield* logInfo("remote update finished", { + requestId: request.requestId, + outcome: next.outcome, + reason: next.reason ?? null, + }); + return true; + } + }); yield* Effect.raceFirst( Effect.gen(function* () { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index d3d5b9877be9..25877552671a 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -599,141 +599,70 @@ export const formatMissingToolsReason = ( return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; }; -const probeWslRuntimeImpl = ( +const probeWslRuntimeImpl = Effect.fn("DesktopWslEnvironment.probeWslRuntimeImpl")(function* ( distro: string | null, linuxAppRoot: string, -): Effect.Effect => - Effect.gen(function* () { - const probe = yield* runWslShell( - distro, - buildWslRuntimeProbeScript(linuxAppRoot), - PROBE_TIMEOUT, - { - resolveNode: false, - }, - ); - const transportFailureReason = formatWslShellTransportFailureReason( - probe.transportFailure, - "the staged runtime", - ); - if (transportFailureReason !== null) { - return { ok: false, reason: transportFailureReason } as const; - } - if (probe.exitCode !== 0) { - const trimmedTail = probe.stderr.trim().slice(-500); - return { - ok: false, - reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, - } as const; - } - const resolvedPath = parseResolvedPath(probe.stdout); - if (resolvedPath === null) { - return { - ok: false, - reason: "WSL login-shell PATH could not be resolved during backend preflight.", - } as const; - } - return { ok: true, resolvedPath } as const; - }); +): Effect.fn.Return { + const probe = yield* runWslShell( + distro, + buildWslRuntimeProbeScript(linuxAppRoot), + PROBE_TIMEOUT, + { + resolveNode: false, + }, + ); + const transportFailureReason = formatWslShellTransportFailureReason( + probe.transportFailure, + "the staged runtime", + ); + if (transportFailureReason !== null) { + return { ok: false, reason: transportFailureReason } as const; + } + if (probe.exitCode !== 0) { + const trimmedTail = probe.stderr.trim().slice(-500); + return { + ok: false, + reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, + } as const; + } + const resolvedPath = parseResolvedPath(probe.stdout); + if (resolvedPath === null) { + return { + ok: false, + reason: "WSL login-shell PATH could not be resolved during backend preflight.", + } as const; + } + return { ok: true, resolvedPath } as const; +}); -const ensureNodePtyImpl = ( +const ensureNodePtyImpl = Effect.fn("DesktopWslEnvironment.ensureNodePtyImpl")(function* ( distro: string | null, linuxRepoRoot: string, options: EnsureWslNodePtyOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - // node-pty lives in the apps/server workspace's node_modules; resolve from - // there rather than the monorepo root, where Bun's hoist layout omits it. - const linuxServerDir = `${linuxRepoRoot}/apps/server`; +): Effect.fn.Return { + // node-pty lives in the apps/server workspace's node_modules; resolve from + // there rather than the monorepo root, where Bun's hoist layout omits it. + const linuxServerDir = `${linuxRepoRoot}/apps/server`; - const probe = yield* runWslShell( - distro, - NODE_PTY_PROBE_SCRIPT(linuxServerDir), - PROBE_TIMEOUT, - options, - ); - const nodePath = parseNodePath(probe.stdout); - const resolvedPath = parseResolvedPath(probe.stdout); - - const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); - if (transportFailureReason !== null) { - return { - ok: false, - reason: transportFailureReason, - fatal: false, - } as const; - } - - if (nodePath === null) { - const toolchainCheck = yield* runWslShell( - distro, - TOOLCHAIN_CHECK_SCRIPT, - TOOLCHAIN_TIMEOUT, - options, - ); - const toolchainTransportFailure = formatWslShellTransportFailureReason( - toolchainCheck.transportFailure, - ); - if (toolchainTransportFailure !== null) { - return { - ok: false, - reason: toolchainTransportFailure, - fatal: false, - retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, - } as const; - } - const report = parseToolchainReport(toolchainCheck.stdout); - const reason = - formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? - "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; - return { ok: false, reason, fatal: true } as const; - } - - if (resolvedPath === null) { - return { - ok: false, - reason: "WSL login-shell PATH could not be resolved during backend preflight.", - fatal: true, - } as const; - } - - if (probe.exitCode === 3) { - return { - ok: false, - reason: - 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', - fatal: true, - } as const; - } - - if (probe.exitCode === 0) { - const rawVersion = parseNodeVersion(probe.stdout); - if ( - rawVersion !== null && - options.nodeEngineRange && - !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) - ) { - const range = options.nodeEngineRange.trim(); - return { - ok: false, - reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, - fatal: true, - } as const; - } - return { ok: true, nodePath, resolvedPath } as const; - } + const probe = yield* runWslShell( + distro, + NODE_PTY_PROBE_SCRIPT(linuxServerDir), + PROBE_TIMEOUT, + options, + ); + const nodePath = parseNodePath(probe.stdout); + const resolvedPath = parseResolvedPath(probe.stdout); - if (options.allowBuild !== true) { - const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); - if (packagedProbeFailure !== null) { - return { - ok: false, - reason: packagedProbeFailure, - fatal: true, - } as const; - } - } + const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); + if (transportFailureReason !== null) { + return { + ok: false, + reason: transportFailureReason, + fatal: false, + } as const; + } + if (nodePath === null) { const toolchainCheck = yield* runWslShell( distro, TOOLCHAIN_CHECK_SCRIPT, @@ -752,55 +681,124 @@ const ensureNodePtyImpl = ( } as const; } const report = parseToolchainReport(toolchainCheck.stdout); + const reason = + formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? + "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; + return { ok: false, reason, fatal: true } as const; + } - if (options.allowBuild !== true) { - // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp, - // or network is needed — and we must not nag the user to install build - // tools they don't need. - const nodeOnlyReason = formatMissingToolsReason( - { - missingTools: report.missingTools.filter((tool) => tool === "node"), - nodeVersion: report.nodeVersion, - }, - options.nodeEngineRange?.trim() || null, - ); + if (resolvedPath === null) { + return { + ok: false, + reason: "WSL login-shell PATH could not be resolved during backend preflight.", + fatal: true, + } as const; + } + + if (probe.exitCode === 3) { + return { + ok: false, + reason: + 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', + fatal: true, + } as const; + } + + if (probe.exitCode === 0) { + const rawVersion = parseNodeVersion(probe.stdout); + if ( + rawVersion !== null && + options.nodeEngineRange && + !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) + ) { + const range = options.nodeEngineRange.trim(); return { ok: false, - reason: - nodeOnlyReason ?? - "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.", + reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, fatal: true, } as const; } + return { ok: true, nodePath, resolvedPath } as const; + } - const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null); - if (missingReason !== null) { - return { ok: false, reason: missingReason, fatal: true } as const; - } - - const build = yield* runWslShell( - distro, - NODE_PTY_BUILD_SCRIPT(linuxServerDir), - BUILD_TIMEOUT, - options, - ); - const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure); - if (buildTransportFailure !== null) { + if (options.allowBuild !== true) { + const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); + if (packagedProbeFailure !== null) { return { ok: false, - reason: buildTransportFailure, - fatal: false, - retryLimit: BUILD_TRANSPORT_RETRY_LIMIT, + reason: packagedProbeFailure, + fatal: true, } as const; } - if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; - const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500); + } + + const toolchainCheck = yield* runWslShell( + distro, + TOOLCHAIN_CHECK_SCRIPT, + TOOLCHAIN_TIMEOUT, + options, + ); + const toolchainTransportFailure = formatWslShellTransportFailureReason( + toolchainCheck.transportFailure, + ); + if (toolchainTransportFailure !== null) { + return { + ok: false, + reason: toolchainTransportFailure, + fatal: false, + retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, + } as const; + } + const report = parseToolchainReport(toolchainCheck.stdout); + + if (options.allowBuild !== true) { + // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp, + // or network is needed — and we must not nag the user to install build + // tools they don't need. + const nodeOnlyReason = formatMissingToolsReason( + { + missingTools: report.missingTools.filter((tool) => tool === "node"), + nodeVersion: report.nodeVersion, + }, + options.nodeEngineRange?.trim() || null, + ); return { ok: false, - reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`, + reason: + nodeOnlyReason ?? + "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.", fatal: true, } as const; - }); + } + + const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null); + if (missingReason !== null) { + return { ok: false, reason: missingReason, fatal: true } as const; + } + + const build = yield* runWslShell( + distro, + NODE_PTY_BUILD_SCRIPT(linuxServerDir), + BUILD_TIMEOUT, + options, + ); + const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure); + if (buildTransportFailure !== null) { + return { + ok: false, + reason: buildTransportFailure, + fatal: false, + retryLimit: BUILD_TRANSPORT_RETRY_LIMIT, + } as const; + } + if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; + const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500); + return { + ok: false, + reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`, + fatal: true, + } as const; +}); const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( distro: string | null, @@ -1059,17 +1057,16 @@ const getUserHomeImpl = ( Effect.orElseSucceed(() => Option.none()), ); -const makeIsAvailable = ( +const makeIsAvailable = Effect.fn("DesktopWslEnvironment.makeIsAvailable")(function* ( platform: NodeJS.Platform, windir: string, -): Effect.Effect => - Effect.gen(function* () { - if (platform !== "win32") return false; - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const wslExePath = path.join(windir, "System32", "wsl.exe"); - return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)); - }); +): Effect.fn.Return { + if (platform !== "win32") return false; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const wslExePath = path.join(windir, "System32", "wsl.exe"); + return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)); +}); export interface DesktopWslEnvironmentTestStub { readonly isAvailable?: boolean; diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts index 37d5d78d9b60..61a461787aaf 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts @@ -48,22 +48,25 @@ export class DesktopWslServerTree extends Context.Service< } >()("@t3tools/desktop/wsl/DesktopWslServerTree") {} -export const forEachBoundedTree = ( +export const forEachBoundedTree = Effect.fn("DesktopWslServerTree.forEachBoundedTree")(function* < + Node, + E, + R, +>( roots: ReadonlyArray, visit: (node: Node) => Effect.Effect, E, R>, -): Effect.Effect => - Effect.gen(function* () { - const pending = [...roots]; - while (pending.length > 0) { - const batch = pending.splice(-COPY_CONCURRENCY); - const children = yield* Effect.forEach(batch, visit, { - concurrency: COPY_CONCURRENCY, - }); - for (const entries of children) { - pending.push(...entries); - } +): Effect.fn.Return { + const pending = [...roots]; + while (pending.length > 0) { + const batch = pending.splice(-COPY_CONCURRENCY); + const children = yield* Effect.forEach(batch, visit, { + concurrency: COPY_CONCURRENCY, + }); + for (const entries of children) { + pending.push(...entries); } - }); + } +}); interface CopyTreeEntry { readonly sourcePath: string; diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index cccb7056cb2b..4ab65816c7ff 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -255,8 +255,8 @@ function iosMajorVersion(): number { return Number.isFinite(major) ? major : 18; } -function nativePushTokenRegistration(observedPushToken?: string) { - return Effect.gen(function* () { +const nativePushTokenRegistration = Effect.fn("remoteRegistration.nativePushTokenRegistration")( + function* (observedPushToken?: string) { if (!canRegisterPushNotifications() || !supportsAgentAwarenessPush()) { return { notificationsEnabled: false, pushToken: null }; } @@ -294,22 +294,21 @@ function nativePushTokenRegistration(observedPushToken?: string) { ? token.data.trim() : null; return { notificationsEnabled: pushToken !== null, pushToken }; - }); -} + }, +); -const relayToken = ( +const relayToken = Effect.fn("remoteRegistration.relayToken")(function* ( operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token", -) => - Effect.gen(function* () { - const provider = relayTokenProvider; - if (!provider) { - return null; - } - return yield* Effect.tryPromise({ - try: provider, - catch: (cause) => new AgentAwarenessOperationError({ operation, cause }), - }); +) { + const provider = relayTokenProvider; + if (!provider) { + return null; + } + return yield* Effect.tryPromise({ + try: provider, + catch: (cause) => new AgentAwarenessOperationError({ operation, cause }), }); +}); function registrationSignature(body: RelayDeviceRegistrationRequest): string { return [ @@ -331,112 +330,110 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { ].join("|"); } -function registerDeviceWithRelay( +const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWithRelay")(function* ( body: RelayDeviceRegistrationRequest, expectedGeneration: number, -): Effect.Effect { - return Effect.gen(function* () { - if (expectedGeneration !== deviceRegistrationGeneration) { - logRegistrationDebug("device registration cancelled before relay request", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); - return; - } - const relayConfig = readRelayConfig(); - if (!relayConfig) { - setRegistrationStatus("unknown"); - return; - } - const token = yield* relayToken("read-device-registration-relay-token"); - if (expectedGeneration !== deviceRegistrationGeneration) { - logRegistrationDebug("device registration cancelled after auth lookup", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); - return; - } - if (!token) { - logRegistrationDebug("relay device registration skipped; user is not signed in"); - setRegistrationStatus("unknown"); - return; - } - - const identity = relayTokenProviderIdentity ?? ""; - const persisted = yield* Effect.tryPromise({ - try: () => loadAgentAwarenessRegistrationRecord(), - catch: (cause) => cause, - }).pipe(Effect.orElseSucceed(() => null)); - if (expectedGeneration !== deviceRegistrationGeneration) { - // Signed out while the record loaded — do not resurrect the cleared - // record or report the previous account's registration as current. - logRegistrationDebug("device registration cancelled after record lookup", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); - return; - } - const payload = body; - const signature = `${relayConfig.url}|${registrationSignature(payload)}`; - // Android registration also silently replays the current card. Collapse - // foreground bursts, but repair missed pushes on cold start or a return - // after time away, just like re-registering an iOS activity token. - const needsAndroidReplay = - body.platform === "android" && - (androidDeviceReplayedAt === null || - Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); - if ( - persisted && - persisted.identity === identity && - persisted.signature === signature && - !needsAndroidReplay - ) { - setRegistrationStatus("registered"); - logRegistrationDebug("relay device registration skipped; already registered for account", { - expectedGeneration, - }); - return; - } - - const client = yield* ManagedRelay.ManagedRelayClient; - logRegistrationDebug("relay device registration request started", { +): Effect.fn.Return { + if (expectedGeneration !== deviceRegistrationGeneration) { + logRegistrationDebug("device registration cancelled before relay request", { expectedGeneration, + currentGeneration: deviceRegistrationGeneration, }); - yield* client.registerDevice({ - clerkToken: token, - payload, + return; + } + const relayConfig = readRelayConfig(); + if (!relayConfig) { + setRegistrationStatus("unknown"); + return; + } + const token = yield* relayToken("read-device-registration-relay-token"); + if (expectedGeneration !== deviceRegistrationGeneration) { + logRegistrationDebug("device registration cancelled after auth lookup", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, }); - if (expectedGeneration !== deviceRegistrationGeneration) { - // Signed out while the request was in flight: the sign-out path already - // reset the status and cleared the record for the next account, so a - // stale success must not overwrite either. - logRegistrationDebug("device registration completed after sign-out; result discarded", { - expectedGeneration, - currentGeneration: deviceRegistrationGeneration, - }); - return; - } - if (body.platform === "android") androidDeviceReplayedAt = Date.now(); + return; + } + if (!token) { + logRegistrationDebug("relay device registration skipped; user is not signed in"); + setRegistrationStatus("unknown"); + return; + } + + const identity = relayTokenProviderIdentity ?? ""; + const persisted = yield* Effect.tryPromise({ + try: () => loadAgentAwarenessRegistrationRecord(), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => null)); + if (expectedGeneration !== deviceRegistrationGeneration) { + // Signed out while the record loaded — do not resurrect the cleared + // record or report the previous account's registration as current. + logRegistrationDebug("device registration cancelled after record lookup", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } + const payload = body; + const signature = `${relayConfig.url}|${registrationSignature(payload)}`; + // Android registration also silently replays the current card. Collapse + // foreground bursts, but repair missed pushes on cold start or a return + // after time away, just like re-registering an iOS activity token. + const needsAndroidReplay = + body.platform === "android" && + (androidDeviceReplayedAt === null || + Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); + if ( + persisted && + persisted.identity === identity && + persisted.signature === signature && + !needsAndroidReplay + ) { setRegistrationStatus("registered"); - yield* Effect.promise(() => - saveAgentAwarenessRegistrationRecord({ - identity, - signature, - }).catch((error: unknown) => { - logRegistrationError("persist registration record failed", error); - }), - ); - logRegistrationDebug("relay device registration request completed", { + logRegistrationDebug("relay device registration skipped; already registered for account", { expectedGeneration, }); + return; + } + + const client = yield* ManagedRelay.ManagedRelayClient; + logRegistrationDebug("relay device registration request started", { + expectedGeneration, }); -} + yield* client.registerDevice({ + clerkToken: token, + payload, + }); + if (expectedGeneration !== deviceRegistrationGeneration) { + // Signed out while the request was in flight: the sign-out path already + // reset the status and cleared the record for the next account, so a + // stale success must not overwrite either. + logRegistrationDebug("device registration completed after sign-out; result discarded", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } + if (body.platform === "android") androidDeviceReplayedAt = Date.now(); + setRegistrationStatus("registered"); + yield* Effect.promise(() => + saveAgentAwarenessRegistrationRecord({ + identity, + signature, + }).catch((error: unknown) => { + logRegistrationError("persist registration record failed", error); + }), + ); + logRegistrationDebug("relay device registration request completed", { + expectedGeneration, + }); +}); -function unregisterDeviceWithRelay(input: { - readonly deviceId: string; - readonly tokenProvider: () => Promise; -}): Effect.Effect { - return Effect.gen(function* () { +const unregisterDeviceWithRelay = Effect.fn("remoteRegistration.unregisterDeviceWithRelay")( + function* (input: { + readonly deviceId: string; + readonly tokenProvider: () => Promise; + }): Effect.fn.Return { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ try: input.tokenProvider, @@ -456,8 +453,8 @@ function unregisterDeviceWithRelay(input: { clerkToken: token, deviceId: input.deviceId, }); - }); -} + }, +); function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean { return ( @@ -536,12 +533,12 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { } } -function readAgentActivitySnapshot(): Effect.Effect< +const readAgentActivitySnapshot: () => Effect.Effect< RelayAgentActivitySnapshotResponse | null, never, ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { +> = Effect.fn("remoteRegistration.readAgentActivitySnapshot")( + function* () { if (!readRelayConfig()) return null; const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { @@ -549,20 +546,19 @@ function readAgentActivitySnapshot(): Effect.Effect< } const client = yield* ManagedRelay.ManagedRelayClient; return yield* client.getAgentActivitySnapshot({ clerkToken: token }); - }).pipe( - Effect.catch((error) => - Effect.sync(() => { - logRegistrationError("agent activity snapshot read failed", error); - return null; - }), - ), - ); -} - -function registerLiveActivityWithRelay( - body: RelayLiveActivityRegistrationRequest, -): Effect.Effect { - return Effect.gen(function* () { + }, + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("agent activity snapshot read failed", error); + return null; + }), + ), +); + +const registerLiveActivityWithRelay = Effect.fn("remoteRegistration.registerLiveActivityWithRelay")( + function* ( + body: RelayLiveActivityRegistrationRequest, + ): Effect.fn.Return { if (!readRelayConfig()) return false; const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { @@ -576,8 +572,8 @@ function registerLiveActivityWithRelay( payload: body, }); return true; - }); -} + }, +); function logRegistrationError(context: string, error: unknown): void { if (!__DEV__) { @@ -693,78 +689,76 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri startPendingDeviceRegistration(); } -function registerDevice( +const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ( input: RegisterDeviceInput = {}, expectedGeneration = deviceRegistrationGeneration, -): Effect.Effect { - return Effect.gen(function* () { - if (!canRegisterPushNotifications()) { - logRegistrationDebug("device registration skipped; platform does not support it"); - return; - } +): Effect.fn.Return { + if (!canRegisterPushNotifications()) { + logRegistrationDebug("device registration skipped; platform does not support it"); + return; + } - logRegistrationDebug("device registration loading local state", { expectedGeneration }); - const [deviceId, storedPreferences] = yield* Effect.all([ - Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "load-device-registration-identifier", - cause, - }), - }), - Effect.tryPromise({ - try: () => loadPreferences(), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "load-device-registration-preferences", - cause, - }), - }), - ]); - const preferences = mergeAgentAwarenessRegistrationPreferences( - storedPreferences, - input.preferencesOverride, - ); - if (expectedGeneration !== deviceRegistrationGeneration) return; - if (relayTokenProvider && relayTokenProviderIdentity) { - configureAndroidAgentNotifications( - deviceId, - relayTokenProviderIdentity, - preferences.liveActivitiesEnabled !== false, - ); - } - const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); - logRegistrationDebug("device registration local state ready", { - expectedGeneration, - notificationsEnabled: pushTokenRegistration.notificationsEnabled, - }); - const bundleId = - Platform.OS === "android" - ? Constants.expoConfig?.android?.package?.trim() - : Constants.expoConfig?.ios?.bundleIdentifier?.trim(); - yield* registerDeviceWithRelay( - makeRelayDeviceRegistrationRequest({ - deviceId, - label: - Constants.deviceName?.trim() || - (Platform.OS === "android" ? "Android device" : "iOS device"), - ...(Platform.OS === "android" - ? { platform: "android" as const, androidApiLevel: Number(Platform.Version) } - : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }), - appVersion: Constants.expoConfig?.version, - ...(bundleId ? { bundleId } : {}), - ...(Platform.OS === "ios" - ? { apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant) } - : {}), - ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), - notificationsEnabled: pushTokenRegistration.notificationsEnabled, - preferences, - }), - expectedGeneration, + logRegistrationDebug("device registration loading local state", { expectedGeneration }); + const [deviceId, storedPreferences] = yield* Effect.all([ + Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-identifier", + cause, + }), + }), + Effect.tryPromise({ + try: () => loadPreferences(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-preferences", + cause, + }), + }), + ]); + const preferences = mergeAgentAwarenessRegistrationPreferences( + storedPreferences, + input.preferencesOverride, + ); + if (expectedGeneration !== deviceRegistrationGeneration) return; + if (relayTokenProvider && relayTokenProviderIdentity) { + configureAndroidAgentNotifications( + deviceId, + relayTokenProviderIdentity, + preferences.liveActivitiesEnabled !== false, ); + } + const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); + logRegistrationDebug("device registration local state ready", { + expectedGeneration, + notificationsEnabled: pushTokenRegistration.notificationsEnabled, }); -} + const bundleId = + Platform.OS === "android" + ? Constants.expoConfig?.android?.package?.trim() + : Constants.expoConfig?.ios?.bundleIdentifier?.trim(); + yield* registerDeviceWithRelay( + makeRelayDeviceRegistrationRequest({ + deviceId, + label: + Constants.deviceName?.trim() || + (Platform.OS === "android" ? "Android device" : "iOS device"), + ...(Platform.OS === "android" + ? { platform: "android" as const, androidApiLevel: Number(Platform.Version) } + : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }), + appVersion: Constants.expoConfig?.version, + ...(bundleId ? { bundleId } : {}), + ...(Platform.OS === "ios" + ? { apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant) } + : {}), + ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), + notificationsEnabled: pushTokenRegistration.notificationsEnabled, + preferences, + }), + expectedGeneration, + ); +}); function registerDeviceForCurrentUser(): Effect.Effect< void, @@ -902,10 +896,12 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { activityPushTokenLocks.clear(); } -export function unregisterAgentAwarenessDeviceForCurrentUser( +export const unregisterAgentAwarenessDeviceForCurrentUser: ( tokenProvider: () => Promise, -): Effect.Effect { - return Effect.gen(function* () { +) => Effect.Effect = Effect.fn( + "remoteRegistration.unregisterAgentAwarenessDeviceForCurrentUser", +)( + function* (tokenProvider: () => Promise) { const deviceId = yield* Effect.tryPromise({ try: () => loadAgentAwarenessDeviceId(), catch: (cause) => @@ -918,47 +914,46 @@ export function unregisterAgentAwarenessDeviceForCurrentUser( return; } yield* unregisterDeviceWithRelay({ deviceId, tokenProvider }); - }).pipe( - Effect.catch((error) => - Effect.sync(() => { - logRegistrationError("device unregistration failed", error); - }), - ), - ); -} - -export function registerLiveActivityPushToken(input: { + }, + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("device unregistration failed", error); + }), + ), +); + +export const registerLiveActivityPushToken = Effect.fn( + "remoteRegistration.registerLiveActivityPushToken", +)(function* (input: { readonly activity: LiveActivity; -}): Effect.Effect { - return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities()) { - return false; - } +}): Effect.fn.Return { + if (!canRegisterRemoteLiveActivities()) { + return false; + } - const lock = observeLiveActivityPushTokens(input.activity); - return yield* lock.withPermit( - Effect.gen(function* () { - const activityPushToken = yield* Effect.tryPromise({ - try: () => input.activity.getPushToken(), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "read-live-activity-push-token", - cause, - }), - }); - if (!activityPushToken) { - logRegistrationDebug( - "live activity push token not available yet; waiting for token event", - { connectionCount: environmentConnections.size }, - ); - return false; - } + const lock = observeLiveActivityPushTokens(input.activity); + return yield* lock.withPermit( + Effect.gen(function* () { + const activityPushToken = yield* Effect.tryPromise({ + try: () => input.activity.getPushToken(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-live-activity-push-token", + cause, + }), + }); + if (!activityPushToken) { + logRegistrationDebug( + "live activity push token not available yet; waiting for token event", + { connectionCount: environmentConnections.size }, + ); + return false; + } - return yield* registerLiveActivityPushTokenValue({ activityPushToken }); - }), - ); - }); -} + return yield* registerLiveActivityPushTokenValue({ activityPushToken }); + }), + ); +}); // iOS can replace an activity's push token while it runs, not only deliver a // late first one. The relay keeps pushing to the old token until APNs rejects @@ -984,38 +979,35 @@ function observeLiveActivityPushTokens( return lock; } -function registerLiveActivityPushTokenValue(input: { +const registerLiveActivityPushTokenValue = Effect.fn( + "remoteRegistration.registerLiveActivityPushTokenValue", +)(function* (input: { readonly activityPushToken: string; -}): Effect.Effect { - return Effect.gen(function* () { - const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); - if ( - acceptedAt !== undefined && - Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS - ) { - return true; - } - const deviceId = yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "load-live-activity-registration-identifier", - cause, - }), - }); - const registered = yield* registerLiveActivityWithRelay({ - deviceId, - activityPushToken: input.activityPushToken, - }); - if (registered) { - registeredActivityPushTokens.set(input.activityPushToken, Date.now()); - logRegistrationDebug("live activity push token registered", { - tokenSuffix: input.activityPushToken.slice(-8), - }); - } - return registered; +}): Effect.fn.Return { + const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); + if (acceptedAt !== undefined && Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS) { + return true; + } + const deviceId = yield* Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-registration-identifier", + cause, + }), }); -} + const registered = yield* registerLiveActivityWithRelay({ + deviceId, + activityPushToken: input.activityPushToken, + }); + if (registered) { + registeredActivityPushTokens.set(input.activityPushToken, Date.now()); + logRegistrationDebug("live activity push token registered", { + tokenSuffix: input.activityPushToken.slice(-8), + }); + } + return registered; +}); function scheduleActiveLiveActivityRegistrationRetry(): void { if (activeLiveActivityRegistrationRetry || !relayTokenProvider) { @@ -1031,117 +1023,113 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { }, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS); } -export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< - void, - never, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { - return; +export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( + "remoteRegistration.refreshActiveLiveActivityRemoteRegistration", +)(function* (): Effect.fn.Return { + if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { + return; + } + + let activities = yield* Effect.try({ + try: () => getAgentLiveActivities(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "list-active-live-activities", + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("active live activity lookup failed", error); + return [] as ReadonlyArray>; + }), + ), + ); + + // The relay tracks exactly one card per device; if concurrent arming ever + // produced extras, end them so only one keeps receiving updates. + if (activities.length > 1) { + for (const extra of activities.slice(1)) { + extra.end("immediate").catch((error: unknown) => { + logRegistrationError("duplicate live activity cleanup failed", error); + }); } + activities = activities.slice(0, 1); + } - let activities = yield* Effect.try({ - try: () => getAgentLiveActivities(), + // Activities are only ever created here, in the foreground, where the + // update token can be observed and registered immediately — the relay never + // remote-starts one (background push-to-start wakes proved too unreliable + // to hand the token over). + if (activities.length === 0) { + const preferences = yield* Effect.tryPromise({ + try: () => loadPreferences(), catch: (cause) => new AgentAwarenessOperationError({ - operation: "list-active-live-activities", + operation: "load-live-activity-prime-preferences", cause, }), - }).pipe( - Effect.catch((error) => - Effect.sync(() => { - logRegistrationError("active live activity lookup failed", error); - return [] as ReadonlyArray>; - }), - ), - ); - - // The relay tracks exactly one card per device; if concurrent arming ever - // produced extras, end them so only one keeps receiving updates. - if (activities.length > 1) { - for (const extra of activities.slice(1)) { - extra.end("immediate").catch((error: unknown) => { - logRegistrationError("duplicate live activity cleanup failed", error); - }); - } - activities = activities.slice(0, 1); - } - - // Activities are only ever created here, in the foreground, where the - // update token can be observed and registered immediately — the relay never - // remote-starts one (background push-to-start wakes proved too unreliable - // to hand the token over). - if (activities.length === 0) { - const preferences = yield* Effect.tryPromise({ - try: () => loadPreferences(), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "load-live-activity-prime-preferences", - cause, - }), - }).pipe(Effect.orElseSucceed(() => null)); - if (preferences?.liveActivitiesEnabled !== false) { - const snapshot = yield* readAgentActivitySnapshot(); - // The snapshot request yields; an arm-on-send may have created the - // card in the meantime. Re-check so two cards are never started. - const armedMeanwhile = yield* Effect.try({ - try: () => getAgentLiveActivities(), - catch: () => [] as ReadonlyArray>, - }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); - if (armedMeanwhile.length > 0) { - activities = [...armedMeanwhile]; - } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { - const aggregate = snapshot.aggregate; - const primed = yield* Effect.try({ - try: () => - startAgentLiveActivity( - { - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }, - liveActivityStaleDate(), - ), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "prime-live-activity", - cause, - }), - }).pipe( - Effect.catch((error) => - Effect.sync(() => { - logRegistrationError("live activity priming failed", error); - return null; - }), + }).pipe(Effect.orElseSucceed(() => null)); + if (preferences?.liveActivitiesEnabled !== false) { + const snapshot = yield* readAgentActivitySnapshot(); + // The snapshot request yields; an arm-on-send may have created the + // card in the meantime. Re-check so two cards are never started. + const armedMeanwhile = yield* Effect.try({ + try: () => getAgentLiveActivities(), + catch: () => [] as ReadonlyArray>, + }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); + if (armedMeanwhile.length > 0) { + activities = [...armedMeanwhile]; + } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { + const aggregate = snapshot.aggregate; + const primed = yield* Effect.try({ + try: () => + startAgentLiveActivity( + { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }, + liveActivityStaleDate(), ), - ); - if (primed) { - logRegistrationDebug("live activity card primed", { - activeCount: aggregate.activeCount, - }); - activities = [primed]; - } + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "prime-live-activity", + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("live activity priming failed", error); + return null; + }), + ), + ); + if (primed) { + logRegistrationDebug("live activity card primed", { + activeCount: aggregate.activeCount, + }); + activities = [primed]; } } } + } - const registrationResults = yield* Effect.forEach(activities, (activity) => - registerLiveActivityPushToken({ activity }).pipe( - Effect.map((registered) => !registered), - Effect.catch((error) => - Effect.sync(() => { - logRegistrationError("active live activity token registration failed", error); - return true; - }), - ), + const registrationResults = yield* Effect.forEach(activities, (activity) => + registerLiveActivityPushToken({ activity }).pipe( + Effect.map((registered) => !registered), + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("active live activity token registration failed", error); + return true; + }), ), - ); + ), + ); - if (registrationResults.some(Boolean)) { - scheduleActiveLiveActivityRegistrationRetry(); - } - }); -} + if (registrationResults.some(Boolean)) { + scheduleActiveLiveActivityRegistrationRetry(); + } +}); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index da2e6187c1d4..a3263ddc770f 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -165,12 +165,8 @@ function privateJwkFromPrivateKey( return { ...publicJwk, d: Base64Url.encode(privateKey) }; } -export function generateDpopProofKeyPair(): Effect.Effect< - DpopProofKeyPair, - CloudDpopError, - Crypto.Crypto -> { - return Effect.gen(function* () { +export const generateDpopProofKeyPair = Effect.fn("dpop.generateDpopProofKeyPair")( + function* (): Effect.fn.Return { let privateKey: Uint8Array; do { privateKey = yield* secureRandomBytes( @@ -188,15 +184,11 @@ export function generateDpopProofKeyPair(): Effect.Effect< publicJwk, thumbprint, }; - }); -} + }, +); -export function loadOrCreateDpopProofKeyPair(): Effect.Effect< - DpopProofKeyPair, - CloudDpopError, - Crypto.Crypto -> { - return Effect.gen(function* () { +export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProofKeyPair")( + function* (): Effect.fn.Return { const stored = yield* Effect.tryPromise({ try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), catch: cloudDpopError("Could not read the DPoP proof key."), @@ -233,8 +225,8 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< catch: cloudDpopError("Could not store the DPoP proof key."), }); return generated; - }); -} + }, +); function normalizeHtu(url: string): Effect.Effect { const normalized = normalizeDpopHtu(url); @@ -243,60 +235,56 @@ function normalizeHtu(url: string): Effect.Effect { : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." })); } -export function createDpopProof(input: { +export const createDpopProof = Effect.fn("dpop.createDpopProof")(function* (input: { readonly method: string; readonly url: string; readonly accessToken?: string; readonly proofKey?: DpopProofKeyPair; -}): Effect.Effect< +}): Effect.fn.Return< { readonly proof: string; readonly thumbprint: string }, CloudDpopError, Crypto.Crypto > { - return Effect.gen(function* () { - const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); - const privateKey = yield* Effect.try({ - try: () => base64UrlToBytes(keyPair.privateJwk.d), - catch: cloudDpopError("Could not import DPoP private key."), - }); - const nowMs = yield* Clock.currentTimeMillis; - const jti = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), - Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")), - ); - const htu = yield* normalizeHtu(input.url); - const header = yield* encodeDpopJwtHeaderJson({ - typ: "dpop+jwt", - alg: "ES256", - jwk: keyPair.publicJwk, - }).pipe( - Effect.map(Base64Url.encode), - Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), - ); - const ath = input.accessToken - ? yield* computeDpopAccessTokenHashEffect(input.accessToken) - : null; - const payload = yield* encodeDpopJwtPayloadJson({ - htm: input.method.toUpperCase(), - htu, - jti, - iat: Math.floor(nowMs / 1_000), - ...(ath ? { ath } : {}), - }).pipe( - Effect.map(Base64Url.encode), - Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")), - ); - const signatureInputHash = yield* sha256Digest( - new TextEncoder().encode(`${header}.${payload}`), - "Could not hash DPoP signing input.", - ); - const signature = yield* Effect.try({ - try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), - catch: cloudDpopError("Could not sign DPoP proof."), - }); - return { - proof: `${header}.${payload}.${Base64Url.encode(signature)}`, - thumbprint: keyPair.thumbprint, - }; + const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); + const privateKey = yield* Effect.try({ + try: () => base64UrlToBytes(keyPair.privateJwk.d), + catch: cloudDpopError("Could not import DPoP private key."), }); -} + const nowMs = yield* Clock.currentTimeMillis; + const jti = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4()), + Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")), + ); + const htu = yield* normalizeHtu(input.url); + const header = yield* encodeDpopJwtHeaderJson({ + typ: "dpop+jwt", + alg: "ES256", + jwk: keyPair.publicJwk, + }).pipe( + Effect.map(Base64Url.encode), + Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), + ); + const ath = input.accessToken ? yield* computeDpopAccessTokenHashEffect(input.accessToken) : null; + const payload = yield* encodeDpopJwtPayloadJson({ + htm: input.method.toUpperCase(), + htu, + jti, + iat: Math.floor(nowMs / 1_000), + ...(ath ? { ath } : {}), + }).pipe( + Effect.map(Base64Url.encode), + Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")), + ); + const signatureInputHash = yield* sha256Digest( + new TextEncoder().encode(`${header}.${payload}`), + "Could not hash DPoP signing input.", + ); + const signature = yield* Effect.try({ + try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), + catch: cloudDpopError("Could not sign DPoP proof."), + }); + return { + proof: `${header}.${payload}.${Base64Url.encode(signature)}`, + thumbprint: keyPair.thumbprint, + }; +}); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index ee5e4a2fc540..3aef69252932 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -153,87 +153,87 @@ type LinkEnvironmentToCloudRequirements = | ManagedRelay.ManagedRelayClient | MobileStorage.MobileStorage; -export function linkEnvironmentToCloudWithPreference( +export const linkEnvironmentToCloudWithPreference = Effect.fn( + "linkEnvironment.linkEnvironmentToCloudWithPreference", +)(function* ( input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, -): Effect.Effect { - return Effect.gen(function* () { - if (!input.connection.bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: "Only a locally paired bearer connection can be linked to the cloud.", - }); - } - const localBearerToken = input.connection.bearerToken; - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelay.ManagedRelayClient; - const storage = yield* MobileStorage.MobileStorage; - const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), - ); - const liveActivitiesEnabled = input.liveActivitiesEnabled; - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), - ), - ); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); - const proof = yield* environmentClient.connect - .linkProof({ - headers: { authorization: `Bearer ${localBearerToken}` }, - payload: { - challenge: challenge.challenge, - relayIssuer: relayUrl, - endpoint: { - httpBaseUrl: input.connection.httpBaseUrl, - wsBaseUrl: input.connection.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, - }, - origin: endpointOrigin(input.connection.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - deviceId, - proof, - notificationsEnabled: true, - liveActivitiesEnabled, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.connection.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, - link, +): Effect.fn.Return { + if (!input.connection.bearerToken) { + return yield* new CloudEnvironmentLinkError({ + message: "Only a locally paired bearer connection can be linked to the cloud.", }); - - yield* environmentClient.connect - .relayConfig({ - headers: { authorization: `Bearer ${localBearerToken}` }, - payload: { - relayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, + } + const localBearerToken = input.connection.bearerToken; + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const storage = yield* MobileStorage.MobileStorage; + const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); + const liveActivitiesEnabled = input.liveActivitiesEnabled; + const challenge = yield* relayClient + .createEnvironmentLinkChallenge({ + clerkToken: input.clerkToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), + ), + ); + const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); + const proof = yield* environmentClient.connect + .linkProof({ + headers: { authorization: `Bearer ${localBearerToken}` }, + payload: { + challenge: challenge.challenge, + relayIssuer: relayUrl, + endpoint: { + httpBaseUrl: input.connection.httpBaseUrl, + wsBaseUrl: input.connection.wsBaseUrl, + providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, - }) - .pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), - ); + origin: endpointOrigin(input.connection.httpBaseUrl), + }, + }) + .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); + const link = yield* relayClient + .linkEnvironment({ + clerkToken: input.clerkToken, + payload: { + deviceId, + proof, + notificationsEnabled: true, + liveActivitiesEnabled, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), + ); + yield* ensureLinkedEnvironmentMatches({ + expectedEnvironmentId: input.connection.environmentId, + expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, + link, }); -} + + yield* environmentClient.connect + .relayConfig({ + headers: { authorization: `Bearer ${localBearerToken}` }, + payload: { + relayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + }) + .pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), + ); +}); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 2569108563b3..97da6d4ca8a5 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -142,44 +142,42 @@ export const generateBrowserDpopKey = Effect.gen(function* () { }; }); -export function createBrowserDpopProof(input: { +export const createBrowserDpopProof = Effect.fn("dpop.createBrowserDpopProof")(function* (input: { readonly method: string; readonly url: string; readonly accessToken?: string; readonly proofKey: BrowserDpopKey; -}): Effect.Effect< +}): Effect.fn.Return< { readonly proof: string; readonly thumbprint: string }, BrowserDpopError, Crypto.Crypto > { - return Effect.gen(function* () { - const normalizedUrl = yield* Effect.try({ - try: () => new URL(input.url), - catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), - }); - normalizedUrl.search = ""; - normalizedUrl.hash = ""; - const jti = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), - Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), - ); - const proof = yield* Effect.tryPromise({ - try: () => - new SignJWT({ - htm: input.method.toUpperCase(), - htu: normalizedUrl.toString(), - jti, - ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + const normalizedUrl = yield* Effect.try({ + try: () => new URL(input.url), + catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), + }); + normalizedUrl.search = ""; + normalizedUrl.hash = ""; + const jti = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4()), + Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), + ); + const proof = yield* Effect.tryPromise({ + try: () => + new SignJWT({ + htm: input.method.toUpperCase(), + htu: normalizedUrl.toString(), + jti, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + }) + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: input.proofKey.publicJwk, }) - .setProtectedHeader({ - typ: "dpop+jwt", - alg: "ES256", - jwk: input.proofKey.publicJwk, - }) - .setIssuedAt() - .sign(input.proofKey.privateKey), - catch: (cause) => dpopError("Could not sign DPoP proof.", cause), - }); - return { proof, thumbprint: input.proofKey.thumbprint }; + .setIssuedAt() + .sign(input.proofKey.privateKey), + catch: (cause) => dpopError("Could not sign DPoP proof.", cause), }); -} + return { proof, thumbprint: input.proofKey.thumbprint }; +}); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index d7a4c3fe8ce4..e6e42dc9218b 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -48,10 +48,10 @@ const relayClientRpcError = (message: string) => (cause: unknown) => cause, }); -function ensureRelayClientAvailable( - environmentId: EnvironmentId, -): Effect.Effect { - return Effect.gen(function* () { +const ensureRelayClientAvailable = Effect.fn("linkEnvironment.ensureRelayClientAvailable")( + function* ( + environmentId: EnvironmentId, + ): Effect.fn.Return { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const status = yield* registry .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) @@ -99,8 +99,8 @@ function ensureRelayClientAvailable( : "The relay client is still unavailable after installation.", }); } - }); -} + }, +); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ @@ -184,163 +184,169 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export function readPrimaryCloudLinkState(input: { +export const readPrimaryCloudLinkState: (input: { readonly target: CloudLinkTarget; -}): Effect.Effect { - return Effect.gen(function* () { +}) => Effect.Effect = + Effect.fn("linkEnvironment.readPrimaryCloudLinkState")(function* (input: { + readonly target: CloudLinkTarget; + }) { const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .linkState({ headers: {} }) .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state."))); - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} + }, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -export function updatePrimaryCloudPreferences(input: { +export const updatePrimaryCloudPreferences: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean; readonly holdWebhooksWhileOffline?: boolean; -}): Effect.Effect { - return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - const { target: _target, ...payload } = input; - return yield* client.connect - .preferences({ - headers: {}, - payload, - }) - .pipe( - Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), - ); - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} +}) => Effect.Effect = Effect.fn( + "linkEnvironment.updatePrimaryCloudPreferences", +)(function* (input: { + readonly target: CloudLinkTarget; + readonly publishAgentActivity: boolean; + readonly holdWebhooksWhileOffline?: boolean; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + const { target: _target, ...payload } = input; + return yield* client.connect + .preferences({ + headers: {}, + payload, + }) + .pipe(Effect.mapError(environmentApiError("Could not update environment cloud preferences."))); +}, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -export function unlinkPrimaryEnvironmentFromCloud(input: { +export const unlinkPrimaryEnvironmentFromCloud: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null; -}): Effect.Effect< +}) => Effect.Effect< void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - yield* client.connect - .unlink({ headers: {} }) - .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); +> = Effect.fn("linkEnvironment.unlinkPrimaryEnvironmentFromCloud")(function* (input: { + readonly target: CloudLinkTarget; + readonly clerkToken: string | null; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + yield* client.connect + .unlink({ headers: {} }) + .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); - const configuredRelayUrl = relayUrl(); - if (configuredRelayUrl && input.clerkToken) { - const relayClient = yield* ManagedRelay.ManagedRelayClient; - yield* relayClient - .unlinkEnvironment({ - clerkToken: input.clerkToken, - environmentId: EnvironmentId.make(input.target.environmentId), - }) - .pipe( - Effect.catch((cause) => - Effect.logWarning("Could not revoke cloud environment link after local unlink.", { - cause, - }), - ), - ); - } - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} + const configuredRelayUrl = relayUrl(); + if (configuredRelayUrl && input.clerkToken) { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient + .unlinkEnvironment({ + clerkToken: input.clerkToken, + environmentId: EnvironmentId.make(input.target.environmentId), + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Could not revoke cloud environment link after local unlink.", { + cause, + }), + ), + ); + } +}, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); export type CloudLinkMode = "managed" | "publish_only"; const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind; -export function linkPrimaryEnvironmentToCloud(input: { +export const linkPrimaryEnvironmentToCloud: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string; readonly mode?: CloudLinkMode; -}): Effect.Effect< +}) => Effect.Effect< void, CloudEnvironmentLinkError, EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; - const providerKind = managedTunnelsEnabled - ? MANAGED_ENDPOINT_PROVIDER_KIND - : PUBLISH_ONLY_PROVIDER_KIND; - const relayClient = yield* ManagedRelay.ManagedRelayClient; - const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - if (managedTunnelsEnabled) { - yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); - } +> = Effect.fn("linkEnvironment.linkPrimaryEnvironmentToCloud")(function* (input: { + readonly target: CloudLinkTarget; + readonly clerkToken: string; + readonly mode?: CloudLinkMode; +}) { + const configuredRelayUrl = relayUrl(); + if (!configuredRelayUrl) { + return yield* new CloudEnvironmentLinkError({ + message: "T3CODE_RELAY_URL is not configured.", + }); + } + const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; + const providerKind = managedTunnelsEnabled + ? MANAGED_ENDPOINT_PROVIDER_KIND + : PUBLISH_ONLY_PROVIDER_KIND; + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + if (managedTunnelsEnabled) { + yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); + } - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError( - `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, - ), + const challenge = yield* relayClient + .createEnvironmentLinkChallenge({ + clerkToken: input.clerkToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError( + `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, ), - ); - const proof = yield* environmentClient.connect - .linkProof({ - headers: {}, - payload: { - challenge: challenge.challenge, - relayIssuer: configuredRelayUrl, - endpoint: { - httpBaseUrl: input.target.httpBaseUrl, - wsBaseUrl: input.target.wsBaseUrl, - providerKind, - }, - origin: endpointOrigin(input.target.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - proof, - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled, + ), + ); + const proof = yield* environmentClient.connect + .linkProof({ + headers: {}, + payload: { + challenge: challenge.challenge, + relayIssuer: configuredRelayUrl, + endpoint: { + httpBaseUrl: input.target.httpBaseUrl, + wsBaseUrl: input.target.wsBaseUrl, + providerKind, }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), - ), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.target.environmentId, - expectedProviderKind: providerKind, - link, - }); + origin: endpointOrigin(input.target.httpBaseUrl), + }, + }) + .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); + const link = yield* relayClient + .linkEnvironment({ + clerkToken: input.clerkToken, + payload: { + proof, + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), + ), + ); + yield* ensureLinkedEnvironmentMatches({ + expectedEnvironmentId: input.target.environmentId, + expectedProviderKind: providerKind, + link, + }); - yield* environmentClient.connect - .relayConfig({ - headers: {}, - payload: { - relayUrl: configuredRelayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); - }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layer)); -} + yield* environmentClient.connect + .relayConfig({ + headers: {}, + payload: { + relayUrl: configuredRelayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + }) + .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); +}, Effect.provide(PrimaryEnvironmentHttpLayer.layer)); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index c57ade36f9ae..4e7bcf3e58ec 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -750,21 +750,21 @@ export const layer = Layer.effectContext( }), Effect.mapError((cause) => persistenceError("load-shell", cause)), ), - saveShell: (environmentId, snapshot) => - Effect.gen(function* () { + saveShell: Effect.fn("storage.saveShell")( + function* (environmentId, snapshot) { const encoded = yield* encodeStoredShellSnapshot({ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, environmentId, snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : persistenceError("save-shell", cause), - ), + }, + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-shell", cause), ), + ), loadServerConfig: (environmentId) => readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe( Effect.flatMap((raw) => { @@ -784,21 +784,21 @@ export const layer = Layer.effectContext( : persistenceError("load-server-config", cause), ), ), - saveServerConfig: (environmentId, config) => - Effect.gen(function* () { + saveServerConfig: Effect.fn("storage.saveServerConfig")( + function* (environmentId, config) { const encoded = yield* encodeStoredServerConfig({ schemaVersion: 1, environmentId, config, }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); yield* writeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId, encoded); - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : persistenceError("save-server-config", cause), - ), + }, + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-server-config", cause), ), + ), loadThread: (environmentId, threadId) => readDatabaseValue( database, @@ -827,8 +827,8 @@ export const layer = Layer.effectContext( }), Effect.mapError((cause) => persistenceError("load-thread", cause)), ), - saveThread: (environmentId, snapshot) => - Effect.gen(function* () { + saveThread: Effect.fn("storage.saveThread")( + function* (environmentId, snapshot) { const encoded = yield* encodeStoredThreadSnapshot({ schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, environmentId, @@ -841,13 +841,13 @@ export const layer = Layer.effectContext( threadCacheKey(environmentId, snapshot.projection.thread.id), encoded, ); - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : persistenceError("save-thread", cause), - ), + }, + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-thread", cause), ), + ), loadVcsRefs: (environmentId, cwd) => readDatabaseValue(database, VCS_REFS_STORE_NAME, vcsRefsCacheKey(environmentId, cwd)).pipe( Effect.flatMap((raw) => { @@ -869,8 +869,8 @@ export const layer = Layer.effectContext( : persistenceError("load-vcs-refs", cause), ), ), - saveVcsRefs: (environmentId, cwd, refs) => - Effect.gen(function* () { + saveVcsRefs: Effect.fn("storage.saveVcsRefs")( + function* (environmentId, cwd, refs) { const encoded = yield* encodeStoredVcsRefs({ schemaVersion: 1, environmentId, @@ -883,13 +883,13 @@ export const layer = Layer.effectContext( vcsRefsCacheKey(environmentId, cwd), encoded, ); - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : persistenceError("save-vcs-refs", cause), - ), + }, + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-vcs-refs", cause), ), + ), removeVcsRefs: (environmentId, cwd) => removeDatabaseValue( database, diff --git a/packages/client-runtime/src/platform/shellCacheEncoding.ts b/packages/client-runtime/src/platform/shellCacheEncoding.ts index 26a501830614..cbfe048b5ead 100644 --- a/packages/client-runtime/src/platform/shellCacheEncoding.ts +++ b/packages/client-runtime/src/platform/shellCacheEncoding.ts @@ -51,14 +51,16 @@ export function makeStoredShellSnapshotEncoder( hasWorked = true; return Effect.void; }); - const encodeMisses = (misses: ReadonlyArray) => - Effect.gen(function* () { + const encodeMisses = + /* Cache encoding runs for shell update batches; avoid tracing each batch. */ + Effect.fnUntraced(function* (misses: ReadonlyArray) { yield* yieldBetweenChunks; const chunk = yield* encodeThreadChunk(misses); chunk.forEach((row, index) => encodedRows.set(misses[index]!, row)); }); - const encodeRows = (rows: ReadonlyArray) => - Effect.gen(function* () { + const encodeRows = + /* Cache encoding runs for shell update batches; avoid tracing each batch. */ + Effect.fnUntraced(function* (rows: ReadonlyArray) { let misses: Array = []; for (const row of rows) { if (encodedRows.has(row)) continue; diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index af8fc0a83667..cfe99e8abcea 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -311,30 +311,29 @@ export function createManagedRelayQueryManager( ) { const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - const observe = ( + const observe = Effect.fn("managedRelayState.observe")(function* ( input: Omit, effect: Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - options?.onQueryEvent?.({ ...input, phase: "start" }); - return yield* effect.pipe( - Effect.onExit((exit) => - Effect.sync(() => { - if (exit._tag === "Success") { - options?.onQueryEvent?.({ ...input, phase: "success" }); - return; - } - const error = Cause.squash(exit.cause); - options?.onQueryEvent?.({ - ...input, - phase: "failure", - message: error instanceof Error ? error.message : String(error), - traceId: findErrorTraceId(error), - }); - }), - ), - ); - }); + ): Effect.fn.Return { + options?.onQueryEvent?.({ ...input, phase: "start" }); + return yield* effect.pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (exit._tag === "Success") { + options?.onQueryEvent?.({ ...input, phase: "success" }); + return; + } + const error = Cause.squash(exit.cause); + options?.onQueryEvent?.({ + ...input, + phase: "failure", + message: error instanceof Error ? error.message : String(error), + traceId: findErrorTraceId(error), + }); + }), + ), + ); + }); const environmentsAtom = Atom.family((accountId: string) => runtime diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index 6f75be719abf..e89d87ffaef8 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -52,13 +52,13 @@ const withEnvironmentCredentials = ( ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })) : request; -const buildEnvironmentAuthHeaders = ( - authorization: PreparedHttpAuthorization | null, - method: HttpMethod.HttpMethod, - url: string, - signer: Option.Option, -): Effect.Effect => - Effect.gen(function* () { +const buildEnvironmentAuthHeaders = Effect.fn("environmentHttpAuth.buildEnvironmentAuthHeaders")( + function* ( + authorization: PreparedHttpAuthorization | null, + method: HttpMethod.HttpMethod, + url: string, + signer: Option.Option, + ): Effect.fn.Return { if (authorization === null) { return {}; } @@ -83,7 +83,8 @@ const buildEnvironmentAuthHeaders = ( ), ); return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof }; - }); + }, +); /** * A DPoP request is T3 Connect work, so its span starts an exported trace that diff --git a/packages/client-runtime/src/state/fileMetadata.ts b/packages/client-runtime/src/state/fileMetadata.ts index cf58cfb015b5..d079f08c00bd 100644 --- a/packages/client-runtime/src/state/fileMetadata.ts +++ b/packages/client-runtime/src/state/fileMetadata.ts @@ -290,14 +290,15 @@ export function createFileMetadataAtoms( ); }); - const seedAfterRead = ( - read: Effect.Effect, - seed: ( - result: A, - isCurrent: (session: RpcSession) => boolean, - ) => Effect.Effect, - ) => - Effect.gen(function* () { + const seedAfterRead = + /* Metadata seeding runs on every file read and is covered by the read operation. */ + Effect.fnUntraced(function* ( + read: Effect.Effect, + seed: ( + result: A, + isCurrent: (session: RpcSession) => boolean, + ) => Effect.Effect, + ) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; const session = yield* SubscriptionRef.get(supervisor.session); const version = session._tag === "Some" ? (invalidationVersions.get(session.value) ?? 0) : 0; diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index e01759225aa8..8bef9762a29d 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -27,25 +27,26 @@ export function createOrchestrationEnvironmentAtoms( return { threadTranscript: createEnvironmentCommand(runtime, { label: "environment-data:orchestration:thread-transcript", - execute: (input: { readonly threadId: ThreadId }) => - Effect.gen(function* () { - const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - const prepared = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(prepared)) { - return yield* new EnvironmentRpcUnavailableError({ - environmentId: supervisor.target.environmentId, - message: "The source environment is not connected.", - }); - } - return yield* fetchEnvironmentThreadTranscript({ - prepared: prepared.value, - threadId: input.threadId, - signer: yield* Effect.serviceOption(ManagedRelayDpopSigner.ManagedRelayDpopSigner), - remoteAuthorization: yield* Effect.serviceOption( - RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, - ), + execute: Effect.fn("orchestration.execute")(function* (input: { + readonly threadId: ThreadId; + }) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: "The source environment is not connected.", }); - }), + } + return yield* fetchEnvironmentThreadTranscript({ + prepared: prepared.value, + threadId: input.threadId, + signer: yield* Effect.serviceOption(ManagedRelayDpopSigner.ManagedRelayDpopSigner), + remoteAuthorization: yield* Effect.serviceOption( + RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, + ), + }); + }), }), v2: { dispatchCommand: createEnvironmentRpcCommand(runtime, { diff --git a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts index 41b45876b770..2d97ce23d1c7 100644 --- a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts +++ b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts @@ -16,17 +16,19 @@ const statKey = (ref: PullRequestRef) => export function persistPullRequestStats(): QueryPersistence { const row = persistQuery(PullRequestDiffStat); return { - load: (environmentId, key) => - Effect.gen(function* () { + load: Effect.fn("pullRequestStatsPersistence.load")( + function* (environmentId, key) { const [, input] = yield* Schema.decodeEffect(requestKey)(key); const rows = yield* Effect.forEach(input.refs, (ref) => row.load(environmentId, statKey(ref)), ); const stats = rows.flatMap((value) => (Option.isSome(value) ? [value.value] : [])); return stats.length ? Option.some({ stats }) : Option.none(); - }).pipe(Effect.orElseSucceed(() => Option.none())), - save: (environmentId, key, result) => - Effect.gen(function* () { + }, + Effect.orElseSucceed(() => Option.none()), + ), + save: Effect.fn("pullRequestStatsPersistence.save")( + function* (environmentId, key, result) { const [, input] = yield* Schema.decodeEffect(requestKey)(key); yield* Effect.forEach(result.stats, (stat) => { const ref = input.refs.find( @@ -37,6 +39,8 @@ export function persistPullRequestStats(): QueryPersistence effect.pipe(Effect.ignore), + ), }; } diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index be7d22c0eb67..684c3d1999f8 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -230,6 +230,62 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 60_000, }), ); + const loadDiff = Effect.fn("PullRequests.loadDiff")(function* (input: PullRequestDiffInput) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const loader = yield* PullRequestDiffLoader.PullRequestDiffLoader; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new EnvironmentHttpConnectionNotReadyError({ + message: "The environment HTTP connection is not ready.", + }); + } + return yield* loader.load(prepared.value, input); + }); + + const runAction = Effect.fn("PullRequests.runAction")(function* ( + input: PullRequestActionInput & { + readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; + }, + ) { + const { resolveMergeMethod, ...actionInput } = input; + let preparedInput = actionInput; + if (actionInput.action === "merge" && resolveMergeMethod !== undefined) { + const { projectId, host, repository, number } = actionInput; + const reference = { projectId, host, repository, number, allowStale: false }; + const detail = yield* routedRequest(WS_METHODS.pullRequestsDetail, reference); + if ( + detail.state !== "open" || + detail.isDraft || + !detail.capabilities.actions.includes("merge") || + !detail.viewerPermissions.actions.includes("merge") + ) { + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "This pull request cannot be merged.", + }); + } + if (detail.capabilities.stackActions) { + const stack = yield* routedRequest(WS_METHODS.pullRequestsStack, reference); + if (stack !== null) { + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "Open this pull request to merge its stack.", + }); + } + } + const mergeMethod = yield* Effect.try({ + try: () => resolveMergeMethod(detail), + catch: (cause) => + new PullRequestOperationError({ + operation: "runAction", + detail: cause instanceof Error ? cause.message : "Could not choose a merge method.", + }), + }); + preparedInput = { ...actionInput, mergeMethod }; + } + return yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); + }); + return { refreshes, linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, { @@ -286,18 +342,7 @@ export function createPullRequestEnvironmentAtoms( label: "environment-data:pull-requests:diff", persistence: persistQuery(PullRequestDiffResult), staleTimeMs: 60_000, - execute: (input: PullRequestDiffInput) => - Effect.gen(function* () { - const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - const loader = yield* PullRequestDiffLoader.PullRequestDiffLoader; - const prepared = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(prepared)) { - return yield* new EnvironmentHttpConnectionNotReadyError({ - message: "The environment HTTP connection is not ready.", - }); - } - return yield* loader.load(prepared.value, input); - }), + execute: loadDiff, }), diffFileContents: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:diff-file-contents", @@ -347,51 +392,7 @@ export function createPullRequestEnvironmentAtoms( label: "environment-data:pull-requests:run-action", // Preparation belongs to the write's lane. Refreshable queries would restart it after // every preceding action, and preparing outside the lane could reorder the clicks. - execute: ( - input: PullRequestActionInput & { - readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; - }, - ) => - Effect.gen(function* () { - const { resolveMergeMethod, ...actionInput } = input; - let preparedInput = actionInput; - if (actionInput.action === "merge" && resolveMergeMethod !== undefined) { - const { projectId, host, repository, number } = actionInput; - const reference = { projectId, host, repository, number, allowStale: false }; - const detail = yield* routedRequest(WS_METHODS.pullRequestsDetail, reference); - if ( - detail.state !== "open" || - detail.isDraft || - !detail.capabilities.actions.includes("merge") || - !detail.viewerPermissions.actions.includes("merge") - ) { - return yield* new PullRequestOperationError({ - operation: "runAction", - detail: "This pull request cannot be merged.", - }); - } - if (detail.capabilities.stackActions) { - const stack = yield* routedRequest(WS_METHODS.pullRequestsStack, reference); - if (stack !== null) { - return yield* new PullRequestOperationError({ - operation: "runAction", - detail: "Open this pull request to merge its stack.", - }); - } - } - const mergeMethod = yield* Effect.try({ - try: () => resolveMergeMethod(detail), - catch: (cause) => - new PullRequestOperationError({ - operation: "runAction", - detail: - cause instanceof Error ? cause.message : "Could not choose a merge method.", - }), - }); - preparedInput = { ...actionInput, mergeMethod }; - } - return yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); - }), + execute: runAction, onSuccess: ({ environmentId, input }, registry) => Effect.sync(() => registry.refresh(preview({ environmentId, input }))), scheduler: commandScheduler, diff --git a/packages/client-runtime/src/state/queryPersistence.ts b/packages/client-runtime/src/state/queryPersistence.ts index 05fdfbc6d711..59c8d5d11f67 100644 --- a/packages/client-runtime/src/state/queryPersistence.ts +++ b/packages/client-runtime/src/state/queryPersistence.ts @@ -26,23 +26,24 @@ export interface QueryPersistence { export function persistQuery(schema: Schema.Codec): QueryPersistence { const codec = Schema.fromJsonString(Schema.toCodecJson(schema)); return { - load: (environmentId, key) => - Effect.gen(function* () { - const store = yield* EnvironmentQueryStore; - if (store === null) return Option.none(); - const stored = yield* store.load(environmentId, key); - if (Option.isNone(stored)) return Option.none(); - return yield* Schema.decodeUnknownEffect(codec)(stored.value).pipe( - Effect.map(Option.some), - Effect.catch(() => Effect.succeedNone), - ); - }), - save: (environmentId, key, value) => - Effect.gen(function* () { + load: Effect.fn("queryPersistence.load")(function* (environmentId, key) { + const store = yield* EnvironmentQueryStore; + if (store === null) return Option.none(); + const stored = yield* store.load(environmentId, key); + if (Option.isNone(stored)) return Option.none(); + return yield* Schema.decodeUnknownEffect(codec)(stored.value).pipe( + Effect.map(Option.some), + Effect.catch(() => Effect.succeedNone), + ); + }), + save: Effect.fn("queryPersistence.save")( + function* (environmentId, key, value) { const store = yield* EnvironmentQueryStore; if (store === null) return; const payload = yield* Schema.encodeEffect(codec)(value); yield* store.save(environmentId, key, payload); - }).pipe(Effect.catch(() => Effect.logWarning("Offline query cache save failed"))), + }, + Effect.catch(() => Effect.logWarning("Offline query cache save failed")), + ), }; } diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 94530b88113a..5a408d61e921 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -299,14 +299,15 @@ export function createAtomCommandScheduler(): AtomCommandScheduler { }; } -export function scheduleAtomCommandEffect( - registry: AtomRegistry.AtomRegistry, - scheduler: AtomCommandScheduler, - concurrency: AtomCommandConcurrency, - input: W, - effect: Effect.Effect, -): Effect.Effect { - return Effect.gen(function* () { +export const scheduleAtomCommandEffect = + /* Every atom command passes through this scheduler; the command owns its tracing boundary. */ + Effect.fnUntraced(function* ( + registry: AtomRegistry.AtomRegistry, + scheduler: AtomCommandScheduler, + concurrency: AtomCommandConcurrency, + input: W, + effect: Effect.Effect, + ): Effect.fn.Return { const context = yield* Effect.context(); const result = yield* Effect.promise((signal) => scheduler.schedule(registry, concurrency, input, async () => { @@ -318,7 +319,6 @@ export function scheduleAtomCommandEffect( ); return result._tag === "Success" ? result.value : yield* Effect.failCause(result.cause); }); -} export async function runAtomCommand( registry: AtomRegistry.AtomRegistry, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c0b996004679..73e46450cfaa 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -149,6 +149,23 @@ export function createThreadEnvironmentAtoms( key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => JSON.stringify([environmentId, input.threadId]), }; + const loadEarlierHistory = Effect.fn("ThreadCommands.loadEarlierHistory")(function* ( + input: LoadEarlierThreadHistoryInput, + ) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const controller = yield* Effect.serviceOption(ThreadHistoryController.ThreadHistoryController); + if (Option.isNone(controller)) { + return { + _tag: "noop", + } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; + } + return yield* controller.value.loadEarlier( + supervisor.target.environmentId, + input.threadId, + input.throughEntryId, + ); + }); + const commands = { create: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:create", @@ -375,23 +392,7 @@ export function createThreadEnvironmentAtoms( }), loadEarlierHistory: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:load-earlier-history", - execute: (input: LoadEarlierThreadHistoryInput) => - Effect.gen(function* () { - const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - const controller = yield* Effect.serviceOption( - ThreadHistoryController.ThreadHistoryController, - ); - if (Option.isNone(controller)) { - return { - _tag: "noop", - } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - } - return yield* controller.value.loadEarlier( - supervisor.target.environmentId, - input.threadId, - input.throughEntryId, - ); - }), + execute: loadEarlierHistory, scheduler, concurrency: { mode: "serial", From cfb602acaa2a413b151213a8ac63cd8d940965ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:31:03 -0700 Subject: [PATCH 012/458] feat(lint): require Effect functions for reusable generator wrappers (#22000) --- oxlint-plugin-t3code/index.ts | 2 + .../rules/prefer-effect-fn.test.ts | 202 ++++++++++++++++++ .../rules/prefer-effect-fn.ts | 116 ++++++++++ tsconfig.base.json | 20 +- vite.config.ts | 9 + 5 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 oxlint-plugin-t3code/rules/prefer-effect-fn.test.ts create mode 100644 oxlint-plugin-t3code/rules/prefer-effect-fn.ts diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index f7326312463a..ede9f4ed948f 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -19,6 +19,7 @@ import noTestInLoop from "./rules/no-test-in-loop.ts"; import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; import preferCatchTags from "./rules/prefer-catch-tags.ts"; +import preferEffectFn from "./rules/prefer-effect-fn.ts"; import requireCenteredScrollGutter from "./rules/require-centered-scroll-gutter.ts"; import requireSuppressionReason from "./rules/require-suppression-reason.ts"; @@ -46,6 +47,7 @@ export default definePlugin({ "no-rpc-permission-bypass": noRpcPermissionBypass, "no-unscoped-has": noUnscopedHas, "prefer-catch-tags": preferCatchTags, + "prefer-effect-fn": preferEffectFn, "require-centered-scroll-gutter": requireCenteredScrollGutter, "require-suppression-reason": requireSuppressionReason, }, diff --git a/oxlint-plugin-t3code/rules/prefer-effect-fn.test.ts b/oxlint-plugin-t3code/rules/prefer-effect-fn.test.ts new file mode 100644 index 000000000000..ba48fc6ad527 --- /dev/null +++ b/oxlint-plugin-t3code/rules/prefer-effect-fn.test.ts @@ -0,0 +1,202 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/prefer-effect-fn"); + +describe("t3code/prefer-effect-fn", () => { + rule.valid( + "allows inline programs and layer construction", + ` + import * as Effect from "effect/Effect"; + import * as Layer from "effect/Layer"; + export const program = Effect.gen(function* () { yield* Effect.void; }); + export const layer = Layer.effectDiscard(Effect.gen(function* () { yield* Effect.void; })); + `, + ); + + rule.valid( + "allows named functions and deliberate untraced functions", + ` + import * as Effect from "effect/Effect"; + export const read = Effect.fn("Files.read")(function* () { yield* Effect.void; }); + export const chunk = Effect.fnUntraced(function* () { yield* Effect.void; }); + `, + ); + + rule.valid( + "allows inline combinator callbacks", + ` + import * as Effect from "effect/Effect"; + export const program = Effect.void.pipe(Effect.flatMap(() => Effect.gen(function* () { + yield* Effect.void; + }))); + `, + ); + + rule.valid( + "allows inline option and error callbacks", + ` + import * as Effect from "effect/Effect"; + import * as Option from "effect/Option"; + export const program = Option.match(Option.none(), { + onNone: () => Effect.gen(function* () { yield* Effect.void; }), + onSome: () => Effect.void, + }); + export const recovered = Effect.fail({ _tag: "Failed" }).pipe(Effect.catchTags({ + Failed: () => Effect.gen(function* () { yield* Effect.void; }), + })); + `, + ); + + rule.valid( + "allows reference default-value callbacks", + ` + import * as Context from "effect/Context"; + import * as Effect from "effect/Effect"; + export const Default = Context.Reference("Default", { + defaultValue: () => Effect.gen(function* () { yield* Effect.void; }), + }); + `, + ); + + for (const name of ["onSome", "onNone", "onSuccess", "onFailure", "defaultValue"]) { + rule.invalid( + `reports service members named ${name}`, + ` + import * as Effect from "effect/Effect"; + export const service = Service.of({ + ${name}: () => Effect.gen(function* () { yield* Effect.void; }), + }); + `, + ); + } + + rule.valid( + "does not mistake other namespaces or shadowed locals for Effect", + ` + import * as Effect from "effect/Effect"; + const Other = { gen: (body: () => unknown) => body() }; + export const other = () => Other.gen(function* () {}); + export function scoped(Effect: typeof Other) { + return Effect.gen(function* () {}); + } + export const effect = Effect.void; + `, + ); + + rule.valid( + "leaves functions with synchronous setup for manual review", + ` + import * as Effect from "effect/Effect"; + export const read = () => { + const state = new Map(); + return Effect.gen(function* () { yield* Effect.void; return state; }); + }; + `, + ); + + rule.invalid( + "reports a named arrow wrapper", + ` + import * as Effect from "effect/Effect"; + export const read = (path: string) => Effect.gen(function* () { + yield* Effect.void; + return path; + }); + `, + (output) => { + assert.match(output, /Effect\.fn/); + assert.match(output, /documented hot paths/); + }, + ); + + rule.invalid( + "reports a block-bodied wrapper", + ` + import * as Effect from "effect/Effect"; + export const read = () => { return Effect.gen(function* () { yield* Effect.void; }); }; + `, + ); + + rule.invalid( + "reports a declaration with pipe transforms", + ` + import * as Effect from "effect/Effect"; + export function read() { + return Effect.gen(function* () { yield* Effect.void; }).pipe(Effect.asVoid); + } + `, + ); + + rule.invalid( + "reports a function expression", + ` + import * as Effect from "effect/Effect"; + export const read = function() { return Effect.gen(function* () { yield* Effect.void; }); }; + `, + ); + + rule.invalid( + "reports service members", + ` + import * as Effect from "effect/Effect"; + export const service = { + read: () => Effect.gen(function* () { yield* Effect.void; }), + }; + `, + ); + + rule.invalid( + "reports class fields", + ` + import * as Effect from "effect/Effect"; + export class Service { + read = () => Effect.gen(function* () { yield* Effect.void; }); + } + `, + ); + + rule.invalid( + "recognizes namespace aliases", + ` + import * as Eff from "effect/Effect"; + export const read = () => Eff.gen(function* () { yield* Eff.void; }); + `, + ); + + rule.invalid( + "recognizes bare-package Effect imports", + ` + import { Effect } from "effect"; + export const read = () => Effect.gen(function* () { yield* Effect.void; }); + `, + ); + + rule.invalid( + "recognizes a direct gen import", + ` + import { gen, void as noop } from "effect/Effect"; + export const read = () => gen(function* () { yield* noop; }); + `, + ); + rule.invalid( + "reports class methods", + ` + import * as Effect from "effect/Effect"; + export class Service { + read() { return Effect.gen(function* () { yield* Effect.void; }); } + } + `, + ); + + rule.valid( + "allows getters that expose an effect value", + ` + import * as Effect from "effect/Effect"; + export class Service { + get read() { return Effect.gen(function* () { yield* Effect.void; }); } + } + `, + ); +}); diff --git a/oxlint-plugin-t3code/rules/prefer-effect-fn.ts b/oxlint-plugin-t3code/rules/prefer-effect-fn.ts new file mode 100644 index 000000000000..f72cbb7c2dff --- /dev/null +++ b/oxlint-plugin-t3code/rules/prefer-effect-fn.ts @@ -0,0 +1,116 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: "Use Effect.fn for reusable generator-based Effect functions.", + }, + }, + create(context) { + const isEffectImport = (identifier: ESTree.IdentifierReference, member: string) => { + let scope = context.sourceCode.getScope(identifier); + while (true) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) { + return variable.defs.some((def) => { + if (def.type !== "ImportBinding" || def.parent?.type !== "ImportDeclaration") { + return false; + } + const source = def.parent.source.value; + if (source === "effect/Effect") { + if (member === "") { + return ( + def.node.type === "ImportSpecifier" && + (def.node.imported.type === "Identifier" + ? def.node.imported.name + : def.node.imported.value) === "gen" + ); + } + return def.node.type === "ImportNamespaceSpecifier"; + } + return ( + source === "effect" && + def.node.type === "ImportSpecifier" && + (def.node.imported.type === "Identifier" + ? def.node.imported.name + : def.node.imported.value) === "Effect" + ); + }); + } + if (scope.upper === null) return false; + scope = scope.upper; + } + }; + + const isGeneratorCall = (expression: ESTree.Expression): boolean => { + if (expression.type !== "CallExpression") return false; + const callee = expression.callee; + if (callee.type === "Identifier") return isEffectImport(callee, ""); + if (callee.type !== "MemberExpression" || callee.computed) return false; + if (callee.property.type !== "Identifier") return false; + if (callee.property.name === "pipe") return isGeneratorCall(callee.object); + return ( + callee.property.name === "gen" && + callee.object.type === "Identifier" && + isEffectImport(callee.object, "gen") + ); + }; + + const check = (node: ESTree.ArrowFunctionExpression | ESTree.Function) => { + if (node.async || node.generator) return; + const parent = node.parent; + if (node.type !== "FunctionDeclaration") { + if (parent.type === "MethodDefinition") { + if (parent.kind !== "method") return; + } else if (parent.type === "Property") { + if (parent.kind !== "init") return; + const name = + parent.key.type === "Identifier" + ? parent.key.name + : parent.key.type === "Literal" + ? parent.key.value + : undefined; + const call = parent.parent.parent; + if (call?.type === "CallExpression") { + let callee = call.callee; + while (callee.type === "CallExpression") callee = callee.callee; + if (callee.type === "MemberExpression" && callee.property.type === "Identifier") { + if ( + ["catchTags", "match", "matchEffect", "matchCauseEffect"].includes( + callee.property.name, + ) || + (name === "defaultValue" && callee.property.name === "Reference") + ) { + return; + } + } + } + } else if (parent.type !== "VariableDeclarator" && parent.type !== "PropertyDefinition") { + return; + } + } + const body = node.body; + if (body === null) return; + let expression: ESTree.Expression | null; + if (body.type === "BlockStatement") { + if (body.body.length !== 1 || body.body[0]?.type !== "ReturnStatement") return; + expression = body.body[0].argument; + } else { + expression = body; + } + if (expression === null || !isGeneratorCall(expression)) return; + context.report({ + node, + message: + 'Use Effect.fn("Service.operation") for reusable Effect functions. Reserve Effect.fnUntraced for documented hot paths or an existing span; keep Effect.gen for inline programs.', + }); + }; + + return { + ArrowFunctionExpression: check, + FunctionExpression: check, + FunctionDeclaration: check, + }; + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 17efedde931b..07c4224cf8d2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,7 +22,9 @@ { "name": "@effect/language-service", "namespaceImportPackages": ["@effect/platform-node", "effect"], + "effectFn": ["span", "inferred-span", "suggested-span"], "diagnosticSeverity": { + "effectFnOpportunity": "suggestion", "anyUnknownInErrorContext": "error", "unsafeEffectTypeAssertion": "error", "instanceOfSchema": "error", @@ -59,7 +61,23 @@ "overrides": [ { "include": ["**/*.test.ts", "**/*.test.tsx"], - "options": { "diagnosticSeverity": { "preferSchemaOverJson": "off" } } + "options": { + "diagnosticSeverity": { + "preferSchemaOverJson": "off", + "effectFnOpportunity": "off" + } + } + }, + { + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.testkit.ts", + "**/*.testkit.tsx", + "**/{test,tests,testkit,testing,fixtures,integration}/**", + "packages/provider-testing/**" + ], + "options": { "diagnosticSeverity": { "effectFnOpportunity": "off" } } } ] } diff --git a/vite.config.ts b/vite.config.ts index e2b3416d88c8..8866c952a5ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -172,10 +172,19 @@ export default defineConfig({ "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", "t3code/prefer-catch-tags": "error", + "t3code/prefer-effect-fn": "error", "t3code/require-centered-scroll-gutter": "error", "t3code/require-suppression-reason": "error", }, overrides: [ + { + files: [ + "**/*.{test,spec,testkit}.{ts,tsx}", + "**/{test,tests,testkit,testing,fixtures,integration}/**", + "packages/provider-testing/**", + ], + rules: { "t3code/prefer-effect-fn": "off" }, + }, { files: ["apps/**/src/**", "packages/**/src/**"], excludeFiles: DOM_TEST_ENVIRONMENT_ALLOWLIST, From e37a7a4f71cb20c483e101e13d57ccf3598ab832 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 19 Oct 2026 18:34:25 -0700 Subject: [PATCH 013/458] fix(web): hosted app skips setup and recommends signing in (#19680) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/web/src/branding.ts | 1 + .../HostedEmptyState.browser.test.tsx | 121 +++++++++++++ apps/web/src/components/HostedEmptyState.tsx | 163 ++++++++++++++++++ ...oudEnvironmentConnectList.browser.test.tsx | 1 + .../cloud/CloudEnvironmentConnectList.tsx | 9 +- .../cloud/ConnectOnboardingDialog.tsx | 8 +- .../components/onboarding/FirstRunGate.tsx | 23 ++- .../onboarding/WelcomeWizard.browser.test.tsx | 26 +++ .../components/onboarding/WelcomeWizard.tsx | 15 +- .../promptLinks/HostedPromptLinkLanding.tsx | 5 +- .../web/src/onboarding/firstRun.logic.test.ts | 111 ++++-------- apps/web/src/onboarding/firstRun.logic.ts | 32 +--- apps/web/src/routes/_chat.index.tsx | 55 +----- docs/user/remote-access.md | 8 +- docs/user/welcome-wizard.md | 16 +- 15 files changed, 407 insertions(+), 187 deletions(-) create mode 100644 apps/web/src/components/HostedEmptyState.browser.test.tsx create mode 100644 apps/web/src/components/HostedEmptyState.tsx diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 528609d36e60..7d14e2cea858 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -25,3 +25,4 @@ export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const DESKTOP_DOWNLOAD_URL = "https://t3.codes/download"; diff --git a/apps/web/src/components/HostedEmptyState.browser.test.tsx b/apps/web/src/components/HostedEmptyState.browser.test.tsx new file mode 100644 index 000000000000..333bcf57a0fd --- /dev/null +++ b/apps/web/src/components/HostedEmptyState.browser.test.tsx @@ -0,0 +1,121 @@ +import type { Discovery } from "@t3tools/client-runtime/relay"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; +type Availability = "checking" | "online"; + +const discovery = vi.hoisted(() => ({ + state: null as Discovery.RelayEnvironmentDiscoveryState | null, + listeners: new Set<() => void>(), + register: vi.fn(), +})); + +vi.mock(import("@clerk/react"), async (original) => ({ + ...(await original()), + useAuth: (() => ({ isLoaded: true, isSignedIn: true })) as never, +})); +vi.mock(import("./clerk/useT3ConnectAuthPrompt"), () => ({ + useT3ConnectAuthPrompt: () => ({ authPrompt: null, openAuthPrompt: vi.fn() }), +})); +vi.mock(import("~/state/use-atom-command"), async (original) => { + const { relayEnvironmentDiscovery } = await import("~/state/relay"); + return { + ...(await original()), + useAtomCommand: ((command: unknown) => + command === relayEnvironmentDiscovery.refresh + ? async () => AsyncResult.success(undefined) + : discovery.register) as never, + }; +}); +vi.mock(import("~/state/environments"), async (original) => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + discovery.listeners.add(listener); + return () => discovery.listeners.delete(listener); + }; + const read = () => discovery.state!; + return { + ...(await original()), + useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read), + }; +}); + +import { T3ConnectSteps } from "./HostedEmptyState"; + +const laptopId = EnvironmentId.make("laptop"); +const desktopId = EnvironmentId.make("desktop"); + +function linked(availability: Record): DiscoveredEnvironments { + return new Map( + [laptopId, desktopId].map((environmentId) => [ + environmentId, + { + environment: { + environmentId, + label: environmentId, + endpoint: { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test/ws", + providerKind: "manual", + }, + linkedAt: "2026-10-19T12:00:00.000Z", + }, + availability: availability[environmentId]!, + status: Option.none(), + error: Option.none(), + }, + ]), + ); +} + +async function publish(environments: DiscoveredEnvironments) { + discovery.state = { environments, refreshing: false, offline: false, error: Option.none() }; + await act(async () => { + for (const listener of discovery.listeners) listener(); + }); +} + +const registeredIds = () => + discovery.register.mock.calls.map(([registration]) => registration.target.environmentId); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + discovery.listeners.clear(); + discovery.state = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), + }; + discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// The first added computer replaces this screen with the app, so every +// registration has to start in the same pass, after all health checks finish. +it("adds every linked computer only after discovery finishes checking them", async () => { + await act(async () => root.render()); + + await publish(linked({ [laptopId]: "checking", [desktopId]: "checking" })); + await publish(linked({ [laptopId]: "online", [desktopId]: "checking" })); + expect(discovery.register).not.toHaveBeenCalled(); + + await publish(linked({ [laptopId]: "online", [desktopId]: "online" })); + expect(registeredIds().toSorted()).toEqual([desktopId, laptopId]); +}); diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx new file mode 100644 index 000000000000..388ca176bc68 --- /dev/null +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -0,0 +1,163 @@ +import { useAuth } from "@clerk/react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import { CheckIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; +import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { isElectron } from "../env"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { useRelayEnvironmentDiscovery } from "../state/environments"; +import { CommandBlock } from "./CommandBlock"; +import { useT3ConnectAuthPrompt } from "./clerk/useT3ConnectAuthPrompt"; +import { CloudEnvironmentConnectRows } from "./cloud/CloudEnvironmentConnectList"; +import { Button } from "./ui/button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; +import { SidebarInset } from "./ui/sidebar"; +import { WorkspacePageHeader } from "./WorkspacePageHeader"; + +/** Home screen for the hosted app before any computer is added. */ +export function HostedEmptyState() { + const cloudEnabled = hasCloudPublicConfig(); + const localEnvironmentOff = isLocalEnvironmentDisabled(); + + return ( + +
+ +
+ + {APP_DISPLAY_NAME} + +
+
+ + + + + {localEnvironmentOff ? "This computer is off" : "No computer connected"} + + + {localEnvironmentOff + ? "Turn it back on, or connect a different computer." + : cloudEnabled + ? "Your agents run on your computer." + : "Your agents run on your computer. Start T3 Code there first."} + + + + {localEnvironmentOff ? ( + + ) : ( +
    + {cloudEnabled ? : } +
+ )} +
+
+
+
+ ); +} + +export function T3ConnectSteps() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + // Adding the first computer unmounts this screen. Wait for all health checks + // so every compatible registration is queued before that happens. + const [autoAddedComputers] = useState(() => new Set()); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const discovered = [...useRelayEnvironmentDiscovery().environments.values()]; + const discoverySettled = + discovered.length > 0 && discovered.every(({ availability }) => availability !== "checking"); + return ( + <> + + {isSignedIn ? ( +

+ + Signed in +

+ ) : ( + + )} +
+ + + {isSignedIn ? ( +
+ + setSelectedIds((current) => { + const next = new Set(current); + if (selected) next.add(environmentId); + else next.delete(environmentId); + return next; + }), + }} + empty={

Waiting for your computer…

} + /> +
+ ) : null} +
+ + ); +} + +function PairingSteps() { + return ( + <> + + + + + + + + ); +} + +function SetupStep({ + number, + children, +}: { + readonly number: number; + readonly children: ReactNode; +}) { + return ( +
  • + {number} +
    {children}
    +
  • + ); +} + +function RunOnComputer({ command }: { readonly command: string }) { + return ( + <> +

    Run on your computer:

    + +
    + Or download the desktop app + + + ); +} diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx index dfe3880654ff..550f77addd45 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx @@ -254,6 +254,7 @@ describe("cloud onboarding discovery", () => { expect(checked()).toBe(false); expect(checkbox().hasAttribute("data-disabled")).toBe(true); expect(text()).toContain("Client not supported"); + expect(text()).toContain("Update your app"); await toggle(); expect(discovery.register).not.toHaveBeenCalled(); await act(async () => diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 20510fc13432..ef4a59df2bf8 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -387,7 +387,14 @@ export function CloudEnvironmentConnectRows({ kind={machineKind} className="size-4 shrink-0 text-muted-foreground" /> - {environment.label} + + {environment.label} + {unsupportedDetail !== null ? ( + + {unsupportedDetail} + + ) : null} + } diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 421eeae0fa8e..409fa77f5fd2 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -12,6 +12,7 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { isHostedStaticApp } from "~/hostedPairing"; import { useEnvironments, usePrimaryEnvironment, @@ -46,7 +47,10 @@ function ConfiguredConnectOnboardingDialog() { ConnectOnboardingOptOutSchema, ); - const { isReady: environmentsReady } = useEnvironments(); + const { environments, isReady: environmentsReady } = useEnvironments(); + // The empty hosted home already adds account computers after sign-in. + const homeScreenAddsComputers = + isHostedStaticApp() && environmentsReady && environments.length === 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); const primarySessionState = useAtomValue( primaryEnvironmentId === null @@ -110,7 +114,7 @@ function ConfiguredConnectOnboardingDialog() { const publishStepDecided = !canManageRelay || controller.linkState.target !== null; if (requestedAccount !== null && openForAccount === null) { - if (optOutAccounts.includes(requestedAccount)) { + if (homeScreenAddsComputers || optOutAccounts.includes(requestedAccount)) { setRequestedAccount(null); } else if (sessionScopesKnown && publishStepDecided) { setRequestedAccount(null); diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index 72fc3c1f0151..56ca254539e7 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -9,7 +9,6 @@ import { useClientSettings, useClientSettingsHydrationStatus, } from "../../hooks/useSettings"; -import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { completeOnboarding } from "../../onboarding/firstRun"; import { isFirstRunWorkspaceProvenanceAuthoritative, @@ -84,7 +83,7 @@ export function FirstRunGate({ const hydrated = hydrationStatus === "ready"; const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); const bootstrapped = useAllEnvironmentShellsBootstrapped(); - const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const { isReady: environmentCatalogReady } = useEnvironments(); const projects = useProjects(); const threadCount = useAtomValue(threadCountAtom); const soleThread = useAtomValue(soleThreadAtom); @@ -121,13 +120,7 @@ export function FirstRunGate({ }); const { decision: nextDecision, persistCompletion } = hostedStatic - ? resolveHostedFirstRunDecision({ - localEnvironmentDisabled: isLocalEnvironmentDisabled(), - hydrated, - completed: onboardingCompletedAt !== null, - catalogReady: environmentCatalogReady, - environmentCount: environments.length, - }) + ? resolveHostedFirstRunDecision(hydrated) : resolveFirstRunDecision({ enabled, hydrated, @@ -146,7 +139,7 @@ export function FirstRunGate({ threadCount, }); - if (decision !== "wizard" && hydrated) { + if (hydrated) { const transitioned = transitionFirstRunGateState(gateState, { type: "evidence", decision: nextDecision, @@ -170,15 +163,21 @@ export function FirstRunGate({ return () => window.clearTimeout(timer); }, [decision, enabled, hydrated]); + // Setup opens by itself only from the home route. A direct link, such as + // Settings, opens as asked so setup can never lock a user out of the app. + const wizardOwnsRoute = pathname === "/"; useEffect(() => { - if (decision === "wizard" && pathname !== "/welcome") { + if (decision === "wizard" && wizardOwnsRoute) { void navigate({ to: "/welcome", replace: true }); } - }, [decision, navigate, pathname]); + }, [decision, navigate, wizardOwnsRoute]); if (settingsReadFailed) { return ; } + if (decision === "wizard" && !wizardOwnsRoute) { + return children; + } if (decision !== "app") { return stalled ? : null; } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx index 760173b49060..95a37ebeaefe 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx @@ -221,3 +221,29 @@ it("keeps setup open when saving completion fails and preserves the import warni }), ); }); + +it("requires explicit Close to complete local setup", async () => { + const onDone = vi.fn(); + await act(async () => root.render()); + await act(async () => { + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + const backdrop = document.querySelector('[data-slot="dialog-backdrop"]')!; + backdrop.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + backdrop.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + backdrop.click(); + }); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + expect(mocks.complete).not.toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + + const close = document.querySelector('button[aria-label="Close"]'); + expect(close).not.toBeNull(); + await act(async () => close!.click()); + expect(mocks.complete).toHaveBeenCalledOnce(); + expect(onDone).toHaveBeenCalledWith(undefined); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 2e87c712a02d..17207a20629f 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -217,11 +217,17 @@ export function WelcomeWizard({ }; return ( - event.cancel()}> + { + event.cancel(); + if (event.reason === "close-press" && !isImporting) void finish(); + }} + > document.getElementById("onboarding-pairing-url") ?? true} > ) : null} + {environment.connection.phase === "unsupported" && environment.connection.error ? ( + + {environment.connection.error} + + ) : null} ))} diff --git a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx index ad0e972aac2d..6a5126085162 100644 --- a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx +++ b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx @@ -9,7 +9,7 @@ import { useLocation } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { type ReactNode, useEffect, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "~/branding"; +import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { isHostedStaticApp } from "~/hostedPairing"; import { useEnvironments } from "~/state/environments"; @@ -21,7 +21,6 @@ import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; const OPENER_STORAGE_KEY = "t3code:prompt-link-opener"; const Opener = Schema.NullOr(Schema.Literals(["app", "browser"])); -const DOWNLOAD_URL = "https://t3.codes/download"; interface LinkSummary { readonly prompt: LinkedPrompt | null; @@ -155,7 +154,7 @@ function HostedPromptLinkLanding({ Continue here ) : null} - diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts index ef61f95eff00..2b5d17e085aa 100644 --- a/apps/web/src/onboarding/firstRun.logic.test.ts +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -256,101 +256,50 @@ describe("transitionFirstRunGateState", () => { }, ); - it("keeps recovery visible while evidence remains pending", () => { - const state = { decision: "pending", stalled: true } as const; + it.each([ + { decision: "pending", stalled: true }, + { decision: "wizard", stalled: false }, + ] as const)("retains $decision while evidence remains pending", (state) => { expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe( state, ); }); - it("allows authoritative wizard evidence to replace an app decision", () => { - expect( - transitionFirstRunGateState( - { decision: "app", stalled: false }, - { type: "evidence", decision: "wizard" }, - ), - ).toEqual({ decision: "wizard", stalled: false }); - }); + it.each([ + { previous: "app", workspaceFresh: true, decision: "wizard" }, + { previous: "wizard", workspaceFresh: false, decision: "app" }, + ] as const)( + "updates $previous to $decision when authoritative workspace evidence changes", + ({ previous, workspaceFresh, decision }) => { + const evidence = resolveFirstRunDecision({ ...freshWorkspace, workspaceFresh }); + expect( + transitionFirstRunGateState( + { decision: previous, stalled: false }, + { type: "evidence", decision: evidence.decision }, + ), + ).toEqual({ decision, stalled: false }); + }, + ); + + it.each(["pending", "app", "wizard"] as const)( + "preserves state identity for unchanged %s evidence", + (decision) => { + const state = { decision, stalled: false }; + expect(transitionFirstRunGateState(state, { type: "evidence", decision })).toBe(state); + }, + ); }); describe("resolveHostedFirstRunDecision", () => { it("keeps the shell hidden until client settings are hydrated", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: false, - completed: false, - catalogReady: true, - environmentCount: 0, - }), - ).toEqual({ - decision: "pending", - persistCompletion: false, - }); - }); - - it("waits for the saved environment catalog before judging a hosted install", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: false, - environmentCount: 0, - }), - ).toEqual({ + expect(resolveHostedFirstRunDecision(false)).toEqual({ decision: "pending", persistCompletion: false, }); }); - it("opens onboarding when a hosted install has no saved environments", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: true, - environmentCount: 0, - }), - ).toEqual({ - decision: "wizard", - persistCompletion: false, - }); - }); - - it("keeps Connections reachable for a remote-only desktop predating onboarding", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: true, - environmentCount: 0, - localEnvironmentDisabled: true, - }), - ).toEqual({ decision: "app", persistCompletion: true }); - }); - - it("backfills onboarding for a hosted install with saved environments", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: true, - environmentCount: 1, - }), - ).toEqual({ - decision: "app", - persistCompletion: true, - }); - }); - - it("opens the app immediately after hosted onboarding is complete", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: true, - catalogReady: false, - environmentCount: 0, - }), - ).toEqual({ + it("opens the app without setup, even with no saved computers", () => { + expect(resolveHostedFirstRunDecision(true)).toEqual({ decision: "app", persistCompletion: false, }); diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts index 73f0f96f81cb..234adc5f71b9 100644 --- a/apps/web/src/onboarding/firstRun.logic.ts +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -48,14 +48,6 @@ interface FirstRunDecisionInput { readonly threadCount: number; } -interface HostedFirstRunDecisionInput { - readonly localEnvironmentDisabled?: boolean; - readonly hydrated: boolean; - readonly completed: boolean; - readonly catalogReady: boolean; - readonly environmentCount: number; -} - export function isFirstRunWorkspaceProvenanceAuthoritative(input: { readonly welcomeReceived: boolean; readonly bootstrapStatus: "pending" | "complete" | null; @@ -71,11 +63,7 @@ export function transitionFirstRunGateState( return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; } - if ( - state.decision === "wizard" || - event.decision === "pending" || - (state.decision === "app" && event.decision !== "wizard") - ) { + if (event.decision === "pending" || state.decision === event.decision) { return state; } @@ -157,23 +145,9 @@ export function resolveFirstRunDecision(input: FirstRunDecisionInput): { : { decision: "app", persistCompletion: input.workspaceAuthoritative }; } -export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { +export function resolveHostedFirstRunDecision(hydrated: boolean): { readonly decision: FirstRunDecision; readonly persistCompletion: boolean; } { - if (!input.hydrated) { - return { decision: "pending", persistCompletion: false }; - } - - if (input.completed) { - return { decision: "app", persistCompletion: false }; - } - - if (!input.catalogReady) { - return { decision: "pending", persistCompletion: false }; - } - - return input.environmentCount === 0 && !input.localEnvironmentDisabled - ? { decision: "wizard", persistCompletion: false } - : { decision: "app", persistCompletion: true }; + return { decision: hydrated ? "app" : "pending", persistCompletion: false }; } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 382c3cbb77ae..b121de4837df 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,14 +1,13 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; -import { LinkIcon, PlusIcon } from "lucide-react"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { isElectron } from "../env"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; +import { HostedEmptyState } from "../components/HostedEmptyState"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; @@ -20,8 +19,6 @@ import { useThreadShells, } from "../state/entities"; import { useEnvironments } from "../state/environments"; -import { APP_DISPLAY_NAME } from "~/branding"; -import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -29,7 +26,7 @@ function ChatIndexRouteView() { if (authGateState.status === "hosted-static") { if (!isReady) return null; - if (environments.length === 0) return ; + if (environments.length === 0) return ; } return ; @@ -117,49 +114,3 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { export const Route = createFileRoute("/_chat/")({ component: ChatIndexRouteView, }); - -function HostedStaticOnboardingState() { - const cloudEnabled = hasCloudPublicConfig(); - const localEnvironmentOff = isLocalEnvironmentDisabled(); - const description = localEnvironmentOff - ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections." - : cloudEnabled - ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." - : "Open Connections and add that machine using its pairing link. This app must be able to reach it."; - - return ( - -
    - -
    - - {APP_DISPLAY_NAME} - -
    -
    - - -
    - -
    - -
    - Connect to a computer running T3 Code - - This app connects to T3 Code running on your computer or a server. Start the T3 Code - desktop app or command-line server on that machine and keep it running. - - {description} -
    - -
    -
    -
    -
    -
    -
    - ); -} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index aaa80e39ca97..7e58cb9b0502 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -21,9 +21,11 @@ server with `t3 serve`. Saving your sign-in alone does not make the machine reachable. On your other device, sign in to the same T3 Connect account and choose the -environment. Over SSH, the CLI prints a browser link and a short code. Open the -link on any device, confirm the code matches, and approve. The CLI continues on -its own, so you do not need to forward an OAuth callback port. +environment. On the app.t3.codes home page with no computer added yet, signing +in adds compatible computers linked to your account. Over SSH, the CLI prints a +browser link and a short code. Open the link on any device, confirm the code +matches, and approve. The CLI continues on its own, so you do not need to forward +an OAuth callback port. T3 Connect renews access credentials when needed without disconnecting a healthy connection. Pull request diffs and provider settings keep working after the diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index 816a24e7d844..41e70a8dccd5 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -1,7 +1,16 @@ # Welcome wizard -T3 Code shows a setup flow when you open a new installation or connect to the -hosted app for the first time. Existing workspaces skip this flow. +T3 Code shows a setup flow when you open a new installation. Existing +workspaces skip this flow. + +The hosted app at app.t3.codes does not open setup. With no computer connected, +it asks you to sign in to T3 Connect, then to run `npx t3 connect` on your +computer. From home, it adds compatible computers linked to your account and +opens the app. + +Close setup while no import is running to go straight to the app. You can connect +computers and agents later from Settings, or open `/welcome` to run setup again. +Direct links, such as Settings, open normally before setup is done. ## Connect your computers @@ -21,6 +30,9 @@ You can add more computers before continuing: the pairing link. You can also run `t3 serve --host
    ` and use `t3 pair` when the server is already reachable on your network. +A computer marked **Client not supported** runs a T3 Code version that cannot +talk to this app. The message under its name says which side to update. + Saved computers and computers discovered through T3 Connect are selected by default. Uncheck any you do not want to set up; this does not disconnect them. Continue when your selected computers are connected. Setup checks From 23ce025f5834cc78b8b2c796a20779568bb636b7 Mon Sep 17 00:00:00 2001 From: Bilal Bayram Date: Tue, 20 Oct 2026 04:35:43 +0300 Subject: [PATCH 014/458] fix(telemetry): honor opt-out for relay tracing (#14413) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 20 ++++++ apps/desktop/src/ipc/methods/window.ts | 9 +++ apps/desktop/src/preload.ts | 2 + apps/server/src/cloud/publicConfig.test.ts | 26 +++++++ apps/server/src/cloud/publicConfig.ts | 5 ++ apps/web/src/cloud/publicConfig.ts | 7 ++ apps/web/src/lib/runtime.test.ts | 79 ++++++++++++++++++++- docs/user/telemetry.md | 10 ++- packages/contracts/src/ipc.ts | 1 + packages/shared/src/otelEnvironment.test.ts | 42 +++++++++++ packages/shared/src/otelEnvironment.ts | 19 ++++- 13 files changed, 218 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 999af3d90f2f..47b342c47def 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getRelayTelemetryEnabled, getSystemLocale, getWindowFullscreenState, openExternal, @@ -95,6 +96,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getRelayTelemetryEnabled); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5843f798ab21..9b11a2283a27 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -30,6 +30,7 @@ export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 97f5404e4522..3459719e2f54 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -27,6 +27,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, + getRelayTelemetryEnabled, getWindowFullscreenState, pasteAsText, pickProjectFavicon, @@ -198,6 +199,25 @@ describe("getLocalEnvironmentBootstraps", () => { }); }); +describe("getRelayTelemetryEnabled", () => { + it.effect("reads the desktop host opt-out before returning the renderer policy", () => + Effect.gen(function* () { + assert.isFalse( + yield* getRelayTelemetryEnabled.handler().pipe( + Effect.provideService(HostProcess.Environment, { + T3CODE_TELEMETRY_ENABLED: "false", + }), + ), + ); + assert.isTrue( + yield* getRelayTelemetryEnabled + .handler() + .pipe(Effect.provideService(HostProcess.Environment, {})), + ); + }), + ); +}); + describe("getWindowFullscreenState", () => { it.effect("reads the current native window state", () => { const window = { isFullScreen: () => true } as Electron.BrowserWindow; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a582dd578abc..9066cf6f1a67 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -16,6 +16,7 @@ import { import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -71,6 +72,14 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getRelayTelemetryEnabled = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getRelayTelemetryEnabled")(function* () { + return isRelayTracingEnabled(yield* HostProcess.Environment); + }), +}); + export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, result: Schema.String, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b57052f4d8a5..bffa02d9ca43 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -82,6 +82,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getRelayTelemetryEnabled: () => + ipcRenderer.sendSync(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL) === true, getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 02276e87d6de..31d8da62909b 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -180,3 +180,29 @@ it("resolves relay client tracing from runtime config with build-time fallback", null, ); }); + +it("product opt-out disables relay tracing with either built-in or runtime credentials", () => { + const fallback = { + tracesUrl: "https://embedded.example.test/v1/traces", + tracesDataset: "embedded-dataset", + tracesToken: "embedded-token", + }; + const env = { + T3CODE_TELEMETRY_ENABLED: "false", + T3CODE_OTEL_SDK_DISABLED: "false", + }; + + assert.equal(resolveRelayClientTracingConfig(env, fallback), null); + assert.equal( + resolveRelayClientTracingConfig( + { + ...env, + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", + }, + fallback, + ), + null, + ); +}); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index eefb9719a9f9..15594f1164e0 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -4,6 +4,7 @@ import { DEFAULT_HOSTED_APP_URL, } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; +import { isRelayTracingEnabled } from "@t3tools/shared/otelEnvironment"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -85,6 +86,10 @@ export function resolveRelayClientTracingConfig( env: Readonly> = process.env, fallback = buildTimeRelayClientTracing, ) { + if (!isRelayTracingEnabled(env)) { + return null; + } + const tracesUrl = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() || fallback.tracesUrl; const tracesDataset = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() || fallback.tracesDataset; diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index 8c3b9073e3b4..13dd7fa53bb4 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -59,6 +59,13 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { } export function resolveRelayTracingConfig() { + // The relay exporter is created during module initialization, so this must be synchronous. + if ( + typeof window !== "undefined" && + window.desktopBridge?.getRelayTelemetryEnabled?.() === false + ) { + return null; + } const { relayTracing } = resolveCloudPublicConfig(); return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken ? { diff --git a/apps/web/src/lib/runtime.test.ts b/apps/web/src/lib/runtime.test.ts index 339ccf711a10..cfba341239f4 100644 --- a/apps/web/src/lib/runtime.test.ts +++ b/apps/web/src/lib/runtime.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Effect from "effect/Effect"; import * as Tracer from "effect/Tracer"; import * as ClientTracer from "../observability/clientTracer"; import { runtime } from "./runtime"; +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + describe("web runtime", () => { it("routes client spans to the exporter client tracing configured", async () => { const exported: Array = []; @@ -26,3 +32,74 @@ describe("web runtime", () => { expect(exported).toEqual(["client.work"]); }); }); + +describe("web runtime relay telemetry policy", () => { + it.each([ + { + surface: "desktop opt-out", + desktopBridge: { getRelayTelemetryEnabled: () => false }, + exports: false, + }, + { + surface: "desktop enabled", + desktopBridge: { getRelayTelemetryEnabled: () => true }, + exports: true, + }, + { surface: "older desktop bridge", desktopBridge: {}, exports: true }, + { + surface: "ordinary browser ignores host opt-out", + desktopBridge: undefined, + exports: true, + }, + ])("applies $surface before the first relay span", async ({ desktopBridge, exports }) => { + vi.resetModules(); + vi.stubEnv("T3CODE_TELEMETRY_ENABLED", "false"); + vi.stubEnv("T3CODE_OTEL_SDK_DISABLED", "true"); + vi.stubEnv("OTEL_SDK_DISABLED", "true"); + vi.stubEnv("VITE_RELAY_OTLP_TRACES_URL", "https://telemetry.example.test/v1/traces"); + vi.stubEnv("VITE_RELAY_OTLP_TRACES_DATASET", "relay-test"); + vi.stubEnv("VITE_RELAY_OTLP_TRACES_TOKEN", "test-token"); + vi.stubGlobal("window", { desktopBridge }); + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + vi.stubGlobal("fetch", fetchFn); + // Re-import after setting runtime policy: static initialization creates the exporter layer. + const { runtime: freshRuntime } = await import("./runtime"); + const { setDelegate } = await import("../observability/clientTracer"); + const userSpans: Array = []; + + try { + expect( + await freshRuntime.runPromise( + Effect.succeed("relay operation completed").pipe( + Effect.withSpan("relay.first-operation"), + withRelayClientTracing, + ), + ), + ).toBe("relay operation completed"); + + setDelegate( + Tracer.make({ + span(options) { + userSpans.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ); + await freshRuntime.runPromise(Effect.void.pipe(Effect.withSpan("user.work"))); + expect(userSpans).toEqual(["user.work"]); + } finally { + setDelegate(null); + // Closing the runtime flushes the exporter without waiting for its batch timer. + await freshRuntime.dispose(); + } + + if (exports) { + expect(fetchFn).toHaveBeenCalledOnce(); + expect(String(fetchFn.mock.calls[0]?.[0])).toBe("https://telemetry.example.test/v1/traces"); + const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); + expect(payload).toContain('"name":"relay.first-operation"'); + } else { + expect(fetchFn).not.toHaveBeenCalled(); + } + }); +}); diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index d25c04e186e5..a25635faaaf6 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -9,10 +9,14 @@ available. Events do not include prompts, responses, file contents, authentication tokens, conversation IDs, raw provider events, or child-agent output. Child-agent token use is excluded from the totals. -To disable collection, set `T3CODE_TELEMETRY_ENABLED=false` in the server's environment before -starting it. This stops product events from being recorded or sent. +T3 Code also sends relay connection traces to Axiom to diagnose T3 Connect connectivity. -The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and +To disable product events and relay tracing, set `T3CODE_TELEMETRY_ENABLED=false` in the server's +environment before starting it. Relay tracing also stops when `T3CODE_OTEL_SDK_DISABLED` or +`OTEL_SDK_DISABLED` turns OpenTelemetry export off. The web app in a browser, including one the +`t3` CLI opens, does not read these variables. + +The desktop app reads `T3CODE_TELEMETRY_ENABLED` from your shell profile (for example `~/.zshrc`) on macOS and Linux, so export it there and restart the app. On Windows, set it as a user environment variable. ## Export diagnostics to your own receiver diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b8423e7ffdf2..4cf76a56c375 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1027,6 +1027,7 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + getRelayTelemetryEnabled?: () => boolean; getPathForFile?: (file: File) => string; getClientPlatform?: () => string; setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index bec05cd47624..3c752e312f07 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -508,3 +508,45 @@ describe("OtelEnvironment", () => { ); }); }); + +describe("isRelayTracingEnabled", () => { + it("defaults on and respects the T3CODE SDK override of the standard kill switch", () => { + assert.isTrue(OtelEnvironment.isRelayTracingEnabled({})); + assert.isFalse(OtelEnvironment.isRelayTracingEnabled({ OTEL_SDK_DISABLED: "true" })); + assert.isTrue( + OtelEnvironment.isRelayTracingEnabled({ + OTEL_SDK_DISABLED: "true", + T3CODE_OTEL_SDK_DISABLED: "false", + }), + ); + assert.isFalse( + OtelEnvironment.isRelayTracingEnabled({ + OTEL_SDK_DISABLED: "false", + T3CODE_OTEL_SDK_DISABLED: "true", + }), + ); + }); + + it.effect("product opt-out beats SDK enable without disabling user-configured tracing", () => + Effect.gen(function* () { + const env = { + T3CODE_TELEMETRY_ENABLED: "false", + T3CODE_OTEL_SDK_DISABLED: "false", + OTEL_SDK_DISABLED: "true", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://user.example.test/v1/traces", + }; + assert.isFalse(OtelEnvironment.isRelayTracingEnabled(env)); + + const otel = yield* load(env); + assert.isFalse(otel.disabled); + assert.deepStrictEqual( + otel.traces, + OtelEnvironment.OtelSignal.Export({ + url: env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + protocol: "http/protobuf", + headers: undefined, + }), + ); + }), + ); +}); diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 80b88fe42ca2..bcbebe3e9bb3 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -246,7 +246,7 @@ const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): }; }; -export const load: Effect.Effect = Config.all({ +const sdkFlags = { t3: flag( "T3CODE_OTEL_SDK_DISABLED", T3CODE_TRUE, @@ -263,6 +263,10 @@ export const load: Effect.Effect = Config.all({ (value) => `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`, ), +}; + +export const load: Effect.Effect = Config.all({ + ...sdkFlags, resource: resourceAttributes, generic: settings("OTEL_EXPORTER_OTLP_"), traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), @@ -311,6 +315,19 @@ export const load: Effect.Effect = Config.all({ Effect.orDie, ); +export const isRelayTracingEnabled = (env: Readonly>) => + Config.all({ + ...sdkFlags, + telemetry: flag("T3CODE_TELEMETRY_ENABLED", T3CODE_TRUE, T3CODE_FALSE, String), + }).pipe( + Effect.map( + ({ telemetry, t3, spec }) => telemetry.value !== false && !(t3.value ?? spec.value ?? false), + ), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(env))), + Effect.orDie, + Effect.runSync, + ); + export type SignalName = "traces" | "metrics" | "logs"; export interface SignalEndpoint { From 26338ea544ba5aff59ffd596c793597682333c58 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 18:35:47 -0700 Subject: [PATCH 015/458] fix(web): paint synchronized terminal updates atomically (#9867) Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> --- apps/web/src/terminal/ghostty/core.ts | 39 ++++- apps/web/src/terminal/ghostty/surface.test.ts | 156 ++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 74 ++++++++- 3 files changed, 259 insertions(+), 10 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 3071bb12a6ce..2d4f0bf1fffe 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -341,14 +341,30 @@ export class GhosttyTerminalCore { this.resize(cols, rows, cellWidth, cellHeight); } - write(data: string | Uint8Array): void { + write(data: string | Uint8Array, onSynchronizedOutputEnd?: () => void): void { this.ensureActive(); const bytes = typeof data === "string" ? encoder.encode(data) : data; if (bytes.length === 0) return; const pointer = this.runtime.alloc(bytes.length); this.runtime.bytes(pointer, bytes.length).set(bytes); - this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length); - this.runtime.free(pointer, bytes.length); + // An existing render deadline belongs to one group. Observe Ghostty before + // the next escape can reopen it; Ghostty still parses OSC/DCS and split VT + // sequences itself. Once that group ends, the remaining bytes can be batched. + let synchronized = onSynchronizedOutputEnd !== undefined && this.isSynchronizedOutput(); + try { + for (let start = 0; start < bytes.length;) { + const nextEscape = synchronized ? bytes.indexOf(0x1b, start + 1) : -1; + const end = nextEscape === -1 ? bytes.length : nextEscape; + this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer + start, end - start); + start = end; + if (synchronized && !this.isSynchronizedOutput()) { + synchronized = false; + onSynchronizedOutputEnd?.(); + } + } + } finally { + this.runtime.free(pointer, bytes.length); + } } resetAndWrite(data: string): void { @@ -490,6 +506,23 @@ export class GhosttyTerminalCore { ); } + isSynchronizedOutput(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2026, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + endSynchronizedOutput(): void { + this.ensureActive(); + this.assertSuccess( + "ghostty_terminal_mode_set", + this.runtime.call("ghostty_terminal_mode_set", this.terminal, 2026, 0), + ); + } + isAlternateScreen(): boolean { this.ensureActive(); this.runtime.bytes(this.scratch, 4).fill(0); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index f967fcff23fd..c486b3a05a3f 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -186,6 +186,7 @@ describe("GhosttyTerminalSurface visibility", () => { const onData = vi.fn<(data: string) => void>(); return { + canvas, mount, frames, paint, @@ -271,6 +272,161 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it("holds the complete frame across split synchronized markers and a DPR change", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1049h\x1b[HOLD first\x1b[2;1HOLD second"); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData.slice(0, 2).map((row) => row.text)).toEqual([ + "OLD first", + "OLD second", + ]); + const { width, height } = harness.canvas; + harness.snapshot.mockClear(); + harness.paint.mockClear(); + + surface.write("\x1b[?20"); + surface.write("26h\x1b[HNEW first\x1b[5n"); + expect(harness.onData).toHaveBeenCalledWith("\x1b[0n"); + harness.flushFrame(); + window.devicePixelRatio = 2; + harness.resize(); + vi.advanceTimersByTime(200); + surface.write("\x1b[2;1HNEW second\x1b[?2026"); + harness.flushFrame(); + expect(harness.snapshot).not.toHaveBeenCalled(); + expect(harness.paint).not.toHaveBeenCalled(); + expect([harness.canvas.width, harness.canvas.height]).toEqual([width, height]); + + surface.write("l"); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + expect(harness.renderedSnapshot.rowData.slice(0, 2).map((row) => row.text)).toEqual([ + "NEW first", + "NEW second", + ]); + expect([harness.canvas.width, harness.canvas.height]).toEqual([width * 2, height * 2]); + expect( + harness.paint.mock.calls + .filter(([operation]) => operation === "fillText") + .map(([, args]) => args[0]), + ).toEqual(expect.arrayContaining(["NEW first", "NEW second"])); + vi.advanceTimersByTime(1_000); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + }); + + it("recovers an unterminated synchronized update at 500ms despite later output", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("before"); + harness.flushFrame(); + harness.snapshot.mockClear(); + harness.paint.mockClear(); + surface.write("\x1b[?2026h\rpartial"); + vi.advanceTimersByTime(499); + surface.write(" frame"); + harness.flushFrame(); + expect(harness.snapshot).not.toHaveBeenCalled(); + expect(harness.paint).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe("partial frame"); + vi.advanceTimersByTime(1_000); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + surface.write("!"); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe("partial frame!"); + }); + + it("gives a synchronized group its own deadline when one write closes A and opens B", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("old"); + harness.flushFrame(); + harness.snapshot.mockClear(); + harness.paint.mockClear(); + surface.write("\x1b[?2026h\rAAA"); + vi.advanceTimersByTime(450); + surface.write("\x1b[?2026l\x1b[?2026h\rBBB"); + vi.advanceTimersByTime(50); + harness.flushFrame(); + expect(harness.snapshot).not.toHaveBeenCalled(); + expect(harness.paint).not.toHaveBeenCalled(); + vi.advanceTimersByTime(449); + harness.flushFrame(); + expect(harness.snapshot).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe("BBB"); + }); + + it.each(["hide", "zero-size", "dispose"] as const)( + "cancels synchronized recovery on %s", + async (action) => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("old"); + harness.flushFrame(); + surface.write("\x1b[?2026h\rnew"); + harness.snapshot.mockClear(); + harness.paint.mockClear(); + if (action === "hide") surface.setVisible(false); + else if (action === "zero-size") { + harness.mount.clientWidth = 0; + harness.resize(); + } else surface.dispose(); + vi.advanceTimersByTime(1_000); + harness.flushFrame(); + expect(harness.snapshot).not.toHaveBeenCalled(); + expect(harness.paint).not.toHaveBeenCalled(); + if (action === "dispose") return; + if (action === "hide") surface.setVisible(true); + else { + harness.mount.clientWidth = 168; + harness.resize(); + } + expect(harness.snapshot).not.toHaveBeenCalled(); + surface.write("\x1b[?2026l"); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe("new"); + }, + ); + + it("replaces an open synchronized update on reset without a stale recovery paint", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?2026hpartial"); + harness.snapshot.mockClear(); + surface.resetAndWrite("replacement"); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe("replacement"); + vi.advanceTimersByTime(1_000); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + }); + + it("paints a grid resize immediately when Ghostty ends synchronized output", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("old"); + harness.flushFrame(); + surface.write("\x1b[?2026h\rnew"); + harness.snapshot.mockClear(); + harness.mount.clientWidth = 176; + harness.resize(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + expect(harness.canvas.width).toBe(176); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe("new"); + vi.advanceTimersByTime(1_000); + harness.flushFrame(); + expect(harness.snapshot).toHaveBeenCalledTimes(1); + }); + it.each([ { platform: "Linux x86_64", primary: { ctrlKey: true } }, { platform: "MacIntel", primary: { metaKey: true } }, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 7ebf0932ad7b..a6edd9c40574 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -33,6 +33,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; const CURSOR_BLINK_INTERVAL_MS = 500; +/** End an unterminated synchronized update instead of freezing the last frame. */ +const SYNCHRONIZED_OUTPUT_RENDER_TIMEOUT_MS = 500; const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; const TERMINAL_FONT_LOAD_VARIANTS = [ "normal 400", @@ -535,6 +537,7 @@ export class GhosttyTerminalSurface { private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; private frame = 0; + private synchronizedRenderTimer: number | null = null; private cursorTimer: number | null = null; private compositionInputToSuppress: string | null = null; private compositionSuppressionTimer: number | null = null; @@ -577,6 +580,12 @@ export class GhosttyTerminalSurface { private focused = false; private resizeNotified = false; private canvasConfigured = false; + private canvasDevicePixelRatio = 0; + private pendingCanvasConfiguration: { + readonly width: number; + readonly height: number; + readonly ratio: number; + } | null = null; private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; @@ -723,7 +732,12 @@ export class GhosttyTerminalSurface { write(data: string): void { if (this.disposed) return; - this.core.write(data); + this.core.write( + data, + this.synchronizedRenderTimer === null + ? undefined + : () => this.cancelSynchronizedRenderTimer(), + ); this.synchronizeMouseTrackingState(); this.cursorOn = true; this.scrollbarDirty = true; @@ -732,6 +746,7 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; + this.cancelSynchronizedRenderTimer(); this.lastMouseMotionData = ""; this.core.resetAndWrite(data); this.synchronizeMouseTrackingState(); @@ -837,15 +852,18 @@ export class GhosttyTerminalSurface { if ( this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight || - !this.canvasConfigured + !this.canvasConfigured || + this.canvasDevicePixelRatio !== ratio ) { - this.canvas.width = pixelWidth; - this.canvas.height = pixelHeight; - this.context.setTransform(ratio, 0, 0, ratio, 0, 0); - this.canvasConfigured = true; + // A backing-size change clears the canvas. Apply it with the gated paint + // so a DPR change cannot erase a frame while synchronized output is open. + this.pendingCanvasConfiguration = { width: pixelWidth, height: pixelHeight, ratio }; this.forceFullRender = true; this.scrollbarDirty = true; shouldRender = true; + } else { + // A resize can return to the painted size before its queued frame runs. + this.pendingCanvasConfiguration = null; } const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); this.mountHeight = height; @@ -1865,7 +1883,8 @@ export class GhosttyTerminalSurface { } private requestRender(): void { - if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return; + if (this.disposed || !this.visible || !this.hasSize) return; + if (this.deferSynchronizedRender() || this.frame !== 0) return; this.frame = window.requestAnimationFrame(() => { this.frame = 0; this.renderFrame(); @@ -1873,6 +1892,29 @@ export class GhosttyTerminalSurface { } private cancelRender(): void { + this.cancelSynchronizedRenderTimer(); + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + if (this.cursorTimer !== null) { + window.clearTimeout(this.cursorTimer); + this.cursorTimer = null; + } + } + + private cancelSynchronizedRenderTimer(): void { + if (this.synchronizedRenderTimer === null) return; + window.clearTimeout(this.synchronizedRenderTimer); + this.synchronizedRenderTimer = null; + } + + private deferSynchronizedRender(): boolean { + if (!this.core.isSynchronizedOutput()) { + this.cancelSynchronizedRenderTimer(); + return false; + } + // A frame queued before the opening marker would now consume partial rows. if (this.frame !== 0) { window.cancelAnimationFrame(this.frame); this.frame = 0; @@ -1881,6 +1923,14 @@ export class GhosttyTerminalSurface { window.clearTimeout(this.cursorTimer); this.cursorTimer = null; } + if (this.synchronizedRenderTimer === null) { + this.synchronizedRenderTimer = window.setTimeout(() => { + this.synchronizedRenderTimer = null; + this.core.endSynchronizedOutput(); + this.renderFrame(); + }, SYNCHRONIZED_OUTPUT_RENDER_TIMEOUT_MS); + } + return true; } private renderFrame(): void { @@ -1895,6 +1945,16 @@ export class GhosttyTerminalSurface { this.cancelRender(); return; } + if (this.deferSynchronizedRender()) return; + const canvasConfiguration = this.pendingCanvasConfiguration; + if (canvasConfiguration !== null) { + this.pendingCanvasConfiguration = null; + this.canvas.width = canvasConfiguration.width; + this.canvas.height = canvasConfiguration.height; + this.context.setTransform(canvasConfiguration.ratio, 0, 0, canvasConfiguration.ratio, 0, 0); + this.canvasConfigured = true; + this.canvasDevicePixelRatio = canvasConfiguration.ratio; + } this.snapshot = this.core.snapshot(); if (!this.blinkEnabled()) this.cursorOn = true; // The origin only moves together with a forced full repaint: partial From 1125b409955b331a42db6976e3c04490bf49ed0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= <38039015+pnupu@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:35:52 +0300 Subject: [PATCH 016/458] fix(server): reset terminal modes a dead shell leaves in inherited history (#9221) Co-authored-by: Claude Fable 5.1 Co-authored-by: Julius Marminge --- apps/server/src/terminal/Manager.test.ts | 214 +++++++++++++++++- apps/server/src/terminal/Manager.ts | 52 ++++- .../src/state/terminalOutput.ts | 1 - .../src/state/terminalSession.ts | 4 +- packages/contracts/src/terminal.ts | 1 + 5 files changed, 260 insertions(+), 12 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c864ba28e5c0..f5a645349afc 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,3 +1,4 @@ +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -45,12 +46,26 @@ import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; +import { + applyTerminalAttachStreamEvent, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + nextTerminalAttachSeedState, + terminalOutputText, +} from "../../../../packages/client-runtime/src/state/terminalSession.ts"; vi.mock("effect/SynchronizedRef", async (importOriginal) => { const original = await importOriginal(); return { ...original, make: vi.fn(original.make) }; }); +// The server omits DOM types, which include TypeScript's WebAssembly declarations. +declare const WebAssembly: { + instantiate( + bytes: ArrayBuffer, + imports: { env: { log: () => void } }, + ): Promise<{ instance: { exports: Record } }>; +}; + const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ @@ -1697,7 +1712,187 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); + expect(reopened.history.startsWith("prompt \u001b[32mok\u001b[0m done\n")).toBe(true); + }), + ); + + it.effect("restores dead-process history without resetting a live terminal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const wasm = yield* fs.readFile( + NodeURL.fileURLToPath( + new URL("../../../web/src/terminal/ghostty/vendor/ghostty-vt.wasm", import.meta.url), + ), + ); + const { instance } = yield* Effect.promise(() => + WebAssembly.instantiate(Uint8Array.from(wasm).buffer, { env: { log: () => {} } }), + ); + const memory = instance.exports.memory as { buffer: ArrayBuffer }; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as (...args: number[]) => number)(...args); + const buffer = call("ghostty_wasm_alloc_u8_array", 16384); + new Uint8Array(memory.buffer, buffer, 16384).fill(0); + const view = () => new DataView(memory.buffer); + view().setUint16(buffer, 100, true); + view().setUint16(buffer + 2, 24, true); + view().setUint32(buffer + 4, 100, true); + expect(call("ghostty_terminal_new", 0, buffer + 8, buffer)).toBe(0); + const terminal = view().getUint32(buffer + 8, true); + expect(call("ghostty_key_encoder_new", 0, buffer + 12)).toBe(0); + expect(call("ghostty_key_event_new", 0, buffer + 16)).toBe(0); + const encoder = view().getUint32(buffer + 12, true); + const key = view().getUint32(buffer + 16, true); + call("ghostty_key_event_set_key", key, 22); // GhosttyKey C in the vendored ABI. + call("ghostty_key_event_set_unshifted_codepoint", key, 99); + new Uint8Array(memory.buffer, buffer + 96, 1).set([99]); + call("ghostty_key_event_set_utf8", key, buffer + 96, 1); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + call("ghostty_key_event_free", key); + call("ghostty_key_encoder_free", encoder); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_u8_array", buffer, 16384); + }), + ); + + // Replay the actual server snapshot in the shipped emulator, not a second mode parser. + const replay = (history: string) => { + call("ghostty_terminal_reset", terminal); + const bytes = new TextEncoder().encode(history); + for (let offset = 0; offset < bytes.length; offset += 16256) { + const chunk = bytes.subarray(offset, offset + 16256); + new Uint8Array(memory.buffer, buffer + 128, chunk.length).set(chunk); + call("ghostty_terminal_vt_write", terminal, buffer + 128, chunk.length); + } + const get = (name: string, field: number) => { + view().setUint32(buffer + 20, 0, true); + expect(call(name, terminal, field, buffer + 20)).toBe(0); + return view().getUint32(buffer + 20, true); + }; + const state = { + cursor: [get("ghostty_terminal_get", 3), get("ghostty_terminal_get", 4)], + screen: get("ghostty_terminal_get", 6), + visible: get("ghostty_terminal_get", 7), + kitty: get("ghostty_terminal_get", 8), + mouse: get("ghostty_terminal_get", 11), + modes: [1, 66, 67, 1004, 1006, 1035, 1036, 2004].map((mode) => + get("ghostty_terminal_mode_get", mode), + ), + }; + call("ghostty_key_encoder_setopt_from_terminal", encoder, terminal); + call("ghostty_key_event_set_action", key, 0); + call("ghostty_key_event_set_mods", key, 0); + expect( + call("ghostty_key_encoder_encode", encoder, key, buffer + 128, 16256, buffer + 80), + ).toBe(0); + const release = new TextDecoder().decode( + new Uint8Array(memory.buffer, buffer + 128, view().getUint32(buffer + 80, true)), + ); + call("ghostty_key_event_set_action", key, 1); + call("ghostty_key_event_set_mods", key, 4); + expect( + call("ghostty_key_encoder_encode", encoder, key, buffer + 128, 16256, buffer + 80), + ).toBe(0); + const modifiedPress = new TextDecoder().decode( + new Uint8Array(memory.buffer, buffer + 128, view().getUint32(buffer + 80, true)), + ); + view().setUint32(buffer + 32, 32, true); + expect(call("ghostty_terminal_select_all", terminal, buffer + 32)).toBe(0); + expect(call("ghostty_terminal_set", terminal, 21, buffer + 32)).toBe(0); + view().setUint32(buffer + 64, 16, true); + view().setUint8(buffer + 72, 1); + view().setUint8(buffer + 73, 1); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + buffer + 64, + buffer + 128, + 16256, + buffer + 80, + ), + ).toBe(0); + const text = new TextDecoder().decode( + new Uint8Array(memory.buffer, buffer + 128, view().getUint32(buffer + 80, true)), + ); + return { ...state, release, modifiedPress, text }; + }; + + const { manager, ptyAdapter, logsDir } = yield* createManager(100); + const logPath = yield* historyLogPath(logsDir); + const text = `${Array.from({ length: 30 }, (_, i) => `history ${i}\r\n`).join("")}prompt % `; + const dirty = `${text}\u001b[>7u\u001b[>0u\u001b[>7u\u001b[?1049h\u001b[7;18H\u001b[?47h\u001b[>4;2m\u001b[>7u\u001b[>0u\u001b[>7u\u001b[?25l\u001b[?1;66;67;1003;1004;1006;2004h\u001b[?1035;1036ldead app`; + const clean = replay(text); + expect(clean).toMatchObject({ screen: 0, visible: 1, kitty: 0, mouse: 0, release: "" }); + expect(clean.text).toContain("history 0"); + expect(replay(dirty)).toMatchObject({ screen: 1, visible: 0, kitty: 7, mouse: 1 }); + expect(replay(dirty).release).not.toBe(""); + yield* fs.writeFileString(logPath, dirty); + + const events: TerminalAttachStreamEvent[] = []; + const outputReceived = yield* Deferred.make(); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "output") yield* Deferred.succeed(outputReceived, undefined); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const first = yield* manager.open(openInput()); + expect(replay(first.history).cursor).toEqual(replay(dirty + "\u001b[?1049l").cursor); + expect(replay(first.history)).toEqual(clean); + expect(replay(first.history + "\u001b[ history.slice(history.indexOf("dead app")); + expect(replay(trimmed(first.history)).cursor).toEqual(replay(trimmed(dirty)).cursor); + expect(replay(first.history + "\u001b[?1049hnext app")).toEqual( + replay(text + "\u001b[?1049hnext app"), + ); + expect(events[0]).toMatchObject({ type: "snapshot", snapshot: { history: first.history } }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + + const freshOutput = "fresh shell\u001b[>7u\u001b[?1003;1004h"; + ptyAdapter.processes[0]!.emitData(freshOutput); + yield* Deferred.await(outputReceived); + const active = yield* manager.open(openInput()); + expect(active.history).toBe(first.history + freshOutput); + expect(replay(active.history)).toMatchObject({ kitty: 7, mouse: 1 }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(events[1]).toMatchObject({ type: "output", data: freshOutput }); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* fs.readFileString(logPath)).toBe(active.history); + + const restarted = yield* TerminalManager.makeWithOptions({ + logsDir, + historyLineLimit: 100, + ptyAdapter, + processKillGraceMs: 1, + }); + const second = yield* restarted.open(openInput()); + expect(replay(second.history)).toEqual(replay(text + "fresh shell")); + yield* restarted.close({ threadId: "thread-1" }); + expect(yield* fs.readFileString(logPath)).toBe(second.history); + const third = yield* restarted.open(openInput()); + expect(replay(third.history)).toEqual(replay(text + "fresh shell")); + expect(ptyAdapter.spawnInputs).toHaveLength(3); + + const cut = yield* createManager(100); + const mixed = `main\u001b[4;10H\u001b[?1049h${"\r".repeat(DEFAULT_MAX_TERMINAL_BUFFER_BYTES)}\u001b[7;18Hretained\u001b[?47hmore`; + yield* fs.writeFileString(yield* historyLogPath(cut.logsDir), mixed); + const snapshot = yield* cut.manager.open(openInput()); + const clientHistory = terminalOutputText( + applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot, + }).output, + ); + expect(replay(clientHistory).cursor).toEqual( + replay("\u001b[7;18Hretained\u001b[?47hmore\u001b[?47l").cursor, + ); + expect(replay(clientHistory)).toMatchObject({ screen: 0, kitty: 0, mouse: 0, release: "" }); + yield* cut.manager.close({ threadId: "thread-1" }); }), ); @@ -1721,7 +1916,9 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, 'prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n'); + expect(reopened.history.startsWith('prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n')).toBe( + true, + ); }), ); @@ -1771,10 +1968,11 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - assert.equal( - reopened.history, - "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", - ); + expect( + reopened.history.startsWith( + "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", + ), + ).toBe(true); }), ); @@ -1793,7 +1991,7 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); + expect(reopened.history.startsWith("before \u001b(Bafter\n")).toBe(true); }), ); @@ -1814,7 +2012,7 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); + expect(reopened.history.startsWith("before \u001b(Bafter\n")).toBe(true); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 858a16aa6f28..406847e17175 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1,6 +1,7 @@ import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { DEFAULT_TERMINAL_ID, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, @@ -986,6 +987,46 @@ export class BoundedTerminalHistory { } } +// Pop beyond Ghostty's eight-entry Kitty stacks on both screens before clearing +// flags. A 1049 round trip preserves the main cursor, text, and scrollback. +const INHERITED_INPUT_RESET = + "\u001b[?1049h\u001b[<100u\u001b[=0;1u\u001b[?1049l" + + "\u001b[?1;9;66;67;1000;1002;1003;1004;1005;1006;1015;2004l" + + "\u001b[?25;1035;1036h\u001b[<100u\u001b[=0;1u\u001b[>4m"; + +function neutralizeInheritedHistory(history: string, maxLines: number, maxBytes: number): string { + // Scan exactly the cold replay window, reserving the entire longest reset so + // neither the server nor the client can trim away the entry after we choose its exit. + const resetBytes = history.includes("\u001b") + ? Buffer.byteLength(INHERITED_INPUT_RESET + "\u001b7\u001b[?1049l") + : 0; + history = new BoundedTerminalHistory( + // The suffix occupies the empty line after a trailing newline, too. + resetBytes > 0 && history.endsWith("\n") ? maxLines - 1 : maxLines, + history, + Math.max(0, maxBytes - resetBytes), + ).value(); + if (!history.includes("\u001b")) return history; + // An unconditional 1049 reset restores a stale saved cursor on the main + // screen. The exit must match the inherited screen's cursor-save behavior. + let alternateScreen: number | undefined; + // eslint-disable-next-line no-control-regex -- Match replayed alternate-screen controls. + for (const match of history.matchAll(/\u001b(?:\[\?([0-9;]+)([hl])|(c))/gu)) { + if (match[3]) alternateScreen = undefined; + for (const parameter of (match[1] ?? "").split(";")) { + const mode = Number(parameter); + if (mode === 47 || mode === 1047 || mode === 1049) { + alternateScreen = match[2] === "h" ? (alternateScreen ?? mode) : undefined; + } + } + } + // A client may trim away the entry, leaving us on main: save its current + // cursor before exiting. On alternate this saves only the alternate cursor. + const leaveAlternateScreen = + alternateScreen === undefined ? "" : `\u001b7\u001b[?${alternateScreen}l`; + return history + leaveAlternateScreen + INHERITED_INPUT_RESET; +} + function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } @@ -2582,7 +2623,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); + const inherited = yield* readHistory(input.threadId, terminalId); + const history = new BoundedTerminalHistory( + historyLineLimit, + neutralizeInheritedHistory( + inherited.value(), + historyLineLimit, + Math.min(historyByteLimit, DEFAULT_MAX_TERMINAL_BUFFER_BYTES), + ), + historyByteLimit, + ); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts index 1b42491ce89a..5c4f8422c1f7 100644 --- a/packages/client-runtime/src/state/terminalOutput.ts +++ b/packages/client-runtime/src/state/terminalOutput.ts @@ -40,7 +40,6 @@ export type TerminalOutputUpdate = readonly data: string; }; -export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; const DEFAULT_TERMINAL_CHUNK_BYTES = 16 * 1024; const MAX_TERMINAL_OUTPUT_CHUNKS = 1_024; const textEncoder = new TextEncoder(); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index b1ef6500d414..eea5514f567d 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -6,16 +6,15 @@ import type { TerminalSummary, ThreadId, } from "@t3tools/contracts"; +import { DEFAULT_MAX_TERMINAL_BUFFER_BYTES } from "@t3tools/contracts"; import { appendOutput, - DEFAULT_MAX_TERMINAL_BUFFER_BYTES, EMPTY_TERMINAL_OUTPUT_STATE, resetOutput, type TerminalOutputState, } from "./terminalOutput.ts"; export { - DEFAULT_MAX_TERMINAL_BUFFER_BYTES, INITIAL_TERMINAL_OUTPUT_CURSOR, readTerminalOutputUpdate, terminalOutputText, @@ -23,6 +22,7 @@ export { type TerminalOutputState, type TerminalOutputUpdate, } from "./terminalOutput.ts"; +export { DEFAULT_MAX_TERMINAL_BUFFER_BYTES } from "@t3tools/contracts"; export interface TerminalSessionState { readonly summary: TerminalSummary | null; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index e4824657a6e6..c00b9300b355 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; export const DEFAULT_TERMINAL_ID = "term-1"; +export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check( From 838f43ff9fdada374440992e4477994f277fad6c Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 20 Oct 2026 01:35:58 +0000 Subject: [PATCH 017/458] fix(web): let new threads choose each repository checkout (#15636) Co-authored-by: Julius Marminge --- .../components/CommandPalette.logic.test.ts | 25 +++++++ .../src/components/CommandPalette.logic.ts | 2 + apps/web/src/components/CommandPalette.tsx | 69 +++++++++++-------- apps/web/src/components/Sidebar.tsx | 8 ++- apps/web/src/environmentGrouping.test.ts | 52 ++++++++++++++ apps/web/src/hooks/useHandleNewThread.test.ts | 52 +++++++++++++- apps/web/src/hooks/useHandleNewThread.ts | 9 +++ apps/web/src/routes/_chat.tsx | 23 ++++--- apps/web/src/sidebarProjectGrouping.ts | 37 ++++++++++ 9 files changed, 234 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 101759fc6c42..4abd2a4655d1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -348,6 +348,31 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildProjectActionItems", () => { + it("shows offline checkout rows but excludes them from keyboard selection", () => { + const local = makeProject(); + const remote = makeProject({ + id: local.id, + environmentId: EnvironmentId.make("offline"), + }); + const items = buildProjectActionItems({ + projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })), + valuePrefix: "new-thread-in", + icon: () => null, + runProject: async () => undefined, + disabled: (project) => project.environmentId === remote.environmentId, + }); + const groups = [{ value: "projects", label: "Projects", items }]; + const { rows, itemValues } = buildCommandPaletteRows(groups); + + expect(rows.filter((row) => row.kind === "item").map((row) => row.item.value)).toEqual([ + `new-thread-in:${remote.environmentId}:${remote.id}`, + `new-thread-in:${local.environmentId}:${local.id}`, + ]); + expect(itemValues).toEqual([`new-thread-in:${local.environmentId}:${local.id}`]); + expect(findHighlightedCommandPaletteItem(groups, items[0]!.value)).toBeNull(); + expect(findHighlightedCommandPaletteItem(groups, items[1]!.value)).toBe(items[1]); + }); + it("shows the grouped display name but keeps the real title for icons", () => { const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); const iconTitles: string[] = []; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 3037ef72159d..9da8c940be33 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -315,6 +315,7 @@ export function buildProjectActionItems(input: { runProject: (project: CommandPaletteProject) => Promise; searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; + disabled?: (project: CommandPaletteProject) => boolean; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -329,6 +330,7 @@ export function buildProjectActionItems(input: { title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), + ...(input.disabled ? { disabled: input.disabled(project) } : {}), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { await input.runProject(project); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 516ab3caa0b3..db89bdb08124 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -25,7 +25,7 @@ import { type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -887,6 +887,20 @@ function OpenCommandPaletteDialog(props: { groups: projectGroups, preferredProjectRef: contextualProjectRef, }); + const isEnvironmentReachable = (environmentId: EnvironmentId) => + environments.some( + (environment) => + environment.environmentId === environmentId && environment.connection.phase === "connected", + ); + const threadPickerProjects = buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: contextualProjectRef, + expandCheckouts: true, + isEnvironmentReachable, + }).map(({ group, targetProject }) => ({ + ...targetProject, + displayName: group.displayName, + })); const pickerProjects = projectPickerEntries.map(({ group, targetProject }) => ({ ...targetProject, displayName: group.displayName, @@ -1163,18 +1177,16 @@ function OpenCommandPaletteDialog(props: { isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); const projectItems = enumerateCommandPaletteItems( buildProjectActionItems({ - projects: pickerProjects.filter((project) => !isScratch(project)), + projects: threadPickerProjects.filter((project) => !isScratch(project)), valuePrefix: "new-thread-in", - searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; - }, + disabled: (project) => !isEnvironmentReachable(project.environmentId), + searchTerms: (project) => [ + projectEnvironmentLocationById.get(project.environmentId)?.label ?? "Remote", + ], renderDescription: (project) => { + const environment = environments.find( + (candidate) => candidate.environmentId === project.environmentId, + ); const location = projectEnvironmentLocationById.get(project.environmentId) ?? { kind: "remote", label: "Remote", @@ -1194,30 +1206,29 @@ function OpenCommandPaletteDialog(props: { {project.workspaceRoot} + {isEnvironmentReachable(project.environmentId) ? null : ( + <> + + + {environment ? connectionStatusTitle(environment.connection) : "Unavailable"} + + + )} ); }, icon: projectFaviconIcon, runProject: async (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const contextualRefBelongsToGroup = - contextualProjectRef !== null && - group?.memberProjectRefs.some( - (projectRef) => - projectRef.environmentId === contextualProjectRef.environmentId && - projectRef.projectId === contextualProjectRef.projectId, - ); - await handleNewThread( - contextualRefBelongsToGroup - ? contextualProjectRef - : scopeProjectRef(project.environmentId, project.id), - ); + await handleNewThread(scopeProjectRef(project.environmentId, project.id), { + environmentSelection: "manual", + }); }, }), ); if (scratchTargetEnvironmentId === null) return projectItems; - const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; + const noProjectIndex = + threadPickerProjects[0] !== undefined && isScratch(threadPickerProjects[0]) ? 0 : 1; return [ ...projectItems.slice(0, noProjectIndex), { @@ -1620,7 +1631,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], }); } @@ -1967,7 +1978,7 @@ function OpenCommandPaletteDialog(props: { case "new-thread-in": pushPaletteView({ addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], }); return; case "change-theme": @@ -2114,7 +2125,9 @@ function OpenCommandPaletteDialog(props: { ? changeThemeItem.groups : currentView?.groups[0]?.value === "appearance" ? changeAppearanceItem.groups - : (currentView?.groups ?? rootGroups); + : currentView?.groups[0]?.value === "new-thread-in" + ? [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }] + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e87861cf93f1..0c4b6f5a0878 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -130,6 +130,7 @@ import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { selectProjectGroupingSettings } from "../logicalProject"; import { + countSidebarProjectChoices, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, @@ -4532,8 +4533,9 @@ export default function Sidebar() { updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); + const projectChoiceCount = countSidebarProjectChoices(projectGroups); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectChoiceCount)) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -4549,7 +4551,7 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (projectChoiceCount <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -4691,7 +4693,7 @@ export default function Sidebar() { newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={projectGroups.length > 1} + showNewThreadInProjectHint={projectChoiceCount > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 5be4dc5c2612..eba9f1f9a9a5 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -12,6 +12,7 @@ import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, + countSidebarProjectChoices, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, @@ -77,6 +78,10 @@ describe("environment grouping", () => { const settingsGroups = buildSidebarProjectSnapshots(input); expect(filters).toHaveLength(1); expect(settingsGroups).toHaveLength(2); + expect([ + countSidebarProjectChoices(filters), + countSidebarProjectChoices(settingsGroups), + ]).toEqual([1, 1]); expect(settingsGroups.map((group) => group.memberProjectRefs)).toEqual([ [{ environmentId: local.environmentId, projectId: local.id }], [{ environmentId: remote.environmentId, projectId: remote.id }], @@ -565,6 +570,53 @@ describe("environment grouping", () => { }); }); + it("offers every physical checkout, with reachable choices before an offline preference", () => { + const primary = makeProject({ repositoryIdentity }); + const worktree = makeProject({ + id: ProjectId.make("local-worktree"), + workspaceRoot: "/tmp/shared-repo-feature", + repositoryIdentity, + }); + const remote = makeProject({ + id: worktree.id, + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const groups = buildSidebarProjectSnapshots({ + projects: [remote, primary, worktree], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + expect(countSidebarProjectChoices(groups)).toBe(3); + const pickerInput = { + groups, + expandCheckouts: true, + isEnvironmentReachable: (environmentId: EnvironmentId) => + environmentId === primaryEnvironmentId, + }; + + const connectedPreference = buildSidebarProjectPickerEntries({ + ...pickerInput, + preferredProjectRef: { environmentId: primaryEnvironmentId, projectId: worktree.id }, + }); + expect(connectedPreference.map(({ targetProject }) => targetProject)).toMatchObject([ + worktree, + primary, + remote, + ]); + + const offlinePreference = buildSidebarProjectPickerEntries({ + ...pickerInput, + preferredProjectRef: { environmentId: remoteEnvironmentId, projectId: remote.id }, + }); + expect(offlinePreference.map(({ targetProject }) => targetProject)).toMatchObject([ + primary, + worktree, + remote, + ]); + }); + it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 3fa77ce4b9ce..b6057f3824dc 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -103,12 +103,14 @@ vi.mock("@effect/atom-react", () => ({ ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin } : testServerConfigs(), })); -vi.mock("@t3tools/client-runtime/environment", () => ({ +vi.mock("@t3tools/client-runtime/environment", async (importOriginal) => ({ + ...(await importOriginal()), scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", () => ({ +vi.mock("@t3tools/contracts", async (importOriginal) => ({ + ...(await importOriginal()), DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, })); @@ -138,7 +140,8 @@ vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, })); -vi.mock("react", () => ({ +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), useCallback: (callback: T) => callback, useMemo: (factory: () => T) => factory(), })); @@ -199,6 +202,49 @@ vi.mock("../uiStateStore", () => ({ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); import { useNewThreadHandler } from "./useHandleNewThread"; +import { useComposerDraftStore } from "../composerDraftStore"; + +it("pins a reused automatic draft to the explicitly selected checkout", async () => { + const { useComposerDraftStore: realDraftStore, DraftId } = + await vi.importActual("../composerDraftStore"); + const { EnvironmentId, ProjectId, ThreadId } = + await vi.importActual("@t3tools/contracts"); + testState.reset(null); + const draftId = DraftId.make("draft-manual-checkout"); + const projectRef = { + environmentId: EnvironmentId.make("environment-ssh"), + projectId: ProjectId.make("project-remote"), + }; + const store = realDraftStore.getState(); + store.setLogicalProjectDraftThreadId( + "remote-project", + { + environmentId: EnvironmentId.make("environment-primary"), + projectId: ProjectId.make("local"), + }, + draftId, + { + threadId: ThreadId.make("thread-manual-checkout"), + environmentSelection: "auto", + loadBalancedEnvironmentId: EnvironmentId.make("environment-primary"), + }, + ); + const getState = vi.spyOn(useComposerDraftStore, "getState").mockReturnValue(store); + try { + const opened = await useNewThreadHandler()(projectRef, { environmentSelection: "manual" }); + + expect(opened?.draftId).toBe(draftId); + expect(store.getDraftSession(draftId)).toMatchObject({ + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + } finally { + getState.mockRestore(); + realDraftStore.setState(store, true); + } +}); describe.each([ ["new", null], diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cfb98fc83bd0..b6143e453244 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -74,6 +74,7 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; + environmentSelection?: "manual"; }, ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); @@ -167,6 +168,10 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; + const selectionOptions = + options?.environmentSelection === "manual" + ? { environmentSelection: "manual" as const, loadBalancedEnvironmentId: null } + : {}; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -267,6 +272,7 @@ export function useNewThreadHandler() { projectRef, emptyStoredDraftThread.draftId, { + ...selectionOptions, threadId: emptyStoredDraftThread.threadId, ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), @@ -309,6 +315,7 @@ export function useNewThreadHandler() { setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options)); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { + ...selectionOptions, threadId: latestActiveDraftThread.threadId, createdAt: latestActiveDraftThread.createdAt, runtimeMode: latestActiveDraftThread.runtimeMode, @@ -340,6 +347,7 @@ export function useNewThreadHandler() { readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null ) { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { + ...selectionOptions, threadId: racedDraft.threadId, createdAt: racedDraft.createdAt, runtimeMode: racedDraft.runtimeMode, @@ -354,6 +362,7 @@ export function useNewThreadHandler() { return { draftId: racedDraft.draftId, threadId: racedDraft.threadId }; } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { + ...selectionOptions, threadId, createdAt, branch: options?.branch ?? null, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 53c11954cec6..de21ef7a1e11 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -13,7 +13,10 @@ import { isPreviewAvailableFor } from "../browser/previewRuntime"; import { usePrimaryEnvironmentId } from "../state/environments"; import { useEnvironmentScope } from "../state/session"; import { selectProjectGroupingSettings } from "../logicalProject"; -import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { + buildSidebarProjectSnapshots, + countSidebarProjectChoices, +} from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useScratchProject } from "../hooks/useScratchProject"; @@ -46,12 +49,14 @@ function ChatRouteGlobalShortcuts() { const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { scratchEnvironmentId, startScratchThread } = useScratchProject(); - const projectGroupCount = buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }).length; + const projectChoiceCount = countSidebarProjectChoices( + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }), + ); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -124,7 +129,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - if (!legacySidebarEnabled && projectGroupCount > 1) { + if (!legacySidebarEnabled && projectChoiceCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -193,7 +198,7 @@ function ChatRouteGlobalShortcuts() { defaultProjectRef, previewOpen, primaryEnvironmentId, - projectGroupCount, + projectChoiceCount, routeThreadRef, scratchEnvironmentId, selectedThreadKeysSize, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 7182405bbd0f..230c2fa16a4b 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -26,6 +26,18 @@ export interface SidebarProjectSnapshot extends Project { remoteEnvironmentLabels: readonly string[]; } +export function countSidebarProjectChoices(groups: ReadonlyArray): number { + let checkoutCount = 0; + let hasScratch = false; + for (const group of groups) { + for (const project of group.memberProjects) { + if (project.isScratch) hasScratch = true; + else checkoutCount += 1; + } + } + return checkoutCount + Number(hasScratch); +} + export function projectGroupsSpanEnvironments( groups: ReadonlyArray>, ): boolean { @@ -173,8 +185,33 @@ export function buildSidebarProjectSnapshots(input: { export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; + expandCheckouts?: boolean; + isEnvironmentReachable?: (environmentId: EnvironmentId) => boolean; }) { const preferredProjectRef = input.preferredProjectRef; + if (input.expandCheckouts) { + // memberProjects already deduplicates physical checkouts. Keep distinct + // worktrees on the same environment as separate choices. + return input.groups + .flatMap((group) => + group.memberProjects.map((targetProject): SidebarProjectPickerEntry => ({ + group, + targetProject, + isPreferred: + preferredProjectRef !== null && + targetProject.environmentId === preferredProjectRef.environmentId && + targetProject.id === preferredProjectRef.projectId, + })), + ) + .toSorted((left, right) => { + const reachability = input.isEnvironmentReachable; + const reachableDelta = reachability + ? Number(reachability(right.targetProject.environmentId)) - + Number(reachability(left.targetProject.environmentId)) + : 0; + return reachableDelta || Number(right.isPreferred) - Number(left.isPreferred); + }); + } const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { const isPreferred = preferredProjectRef ? group.memberProjectRefs.some( From d1da2355cfd48de60a43cd08b63f0d2942936c33 Mon Sep 17 00:00:00 2001 From: Ishaan Kothari Date: Mon, 19 Oct 2026 18:36:03 -0700 Subject: [PATCH 018/458] fix(relay): end idle Live Activities after the display window (#11943) Co-authored-by: Julius Marminge --- .../AgentActivityPublisher.test.ts | 63 +++++++++++++++++++ .../agentActivity/AgentActivityPublisher.ts | 63 ++++++++++++++++++- .../src/agentActivity/ApnsDeliveries.test.ts | 59 +++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 35 +++++++++++ .../src/agentActivity/FcmDeliveries.test.ts | 1 + .../relay/src/agentActivity/LiveActivities.ts | 46 +++++++++++++- .../agentActivity/MobileRegistrations.test.ts | 2 + infra/relay/src/http/Api.test.ts | 1 + infra/relay/src/worker.ts | 10 ++- 9 files changed, 277 insertions(+), 3 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 209c8beed703..268147e63b85 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -2,6 +2,7 @@ import type { RelayAgentActivityState, RelayDeliveryResult } from "@t3tools/cont import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; @@ -58,6 +59,7 @@ function makeLiveActivities( return { register: () => Effect.void, listTargets: () => Effect.succeed([]), + listIdleArmedTargets: () => Effect.succeed([]), markDelivery: () => Effect.void, markStartQueued: () => Effect.void, clearStartQueued: () => Effect.void, @@ -268,6 +270,67 @@ describe("AgentActivityPublisher", () => { }); }); + it.effect("ends an idle card after finished work ages out without another publish", () => { + const sent: Array[0]> = + []; + let activeStates: ReadonlyArray = [state]; + const endIdle = AgentActivityPublisher.AgentActivityPublisher.pipe( + Effect.flatMap((publisher) => publisher.endIdleLiveActivities), + Effect.provide( + layerPublisher.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ listForUser: () => Effect.sync(() => activeStates) }), + ), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + listIdleArmedTargets: () => + Effect.succeed([{ user_id: "dev:julius", device_id: "device-1" }]), + listTargets: () => + Effect.succeed([ + { ...target("device-1"), activity_push_token: "activity-token" }, + target("device-2"), + ]), + }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + sent.push(input); + return null; + }), + }), + ), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + yield* TestClock.adjust("1 hour"); + yield* endIdle; + expect(sent).toEqual([]); + + activeStates = [{ ...state, phase: "completed", updatedAt: "1970-01-01T01:00:00.000Z" }]; + yield* TestClock.adjust("15 minutes"); + yield* endIdle; + expect(sent).toEqual([]); + + yield* TestClock.adjust("5 minutes"); + yield* endIdle; + expect(sent).toMatchObject([ + { target: { device_id: "device-1" }, aggregate: null, replay: true }, + ]); + }); + }); + it.effect("publishes listed targets through the APNs delivery service", () => { const firstTarget = target("device-1"); const secondTarget = target("device-2"); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 61a420dd7858..6d6d683d3fb0 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -1,4 +1,7 @@ -import { makeAggregateState } from "./agentActivityAggregate.ts"; +import { + makeAggregateState, + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, +} from "./agentActivityAggregate.ts"; export { makeAggregateState, TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, @@ -8,6 +11,7 @@ import type { RelayDeliveryResult, RelayPublishResponse, } from "@t3tools/contracts/relay"; +import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -44,6 +48,10 @@ export class AgentActivityPublisher extends Context.Service< readonly userId: string; readonly deviceId: string; }) => Effect.Effect; + readonly endIdleLiveActivities: Effect.Effect< + void, + LiveActivities.LiveActivityIdleTargetListPersistenceError + >; } >()("t3code-relay/agentActivity/AgentActivityPublisher") {} @@ -143,6 +151,59 @@ export const make = Effect.gen(function* () { replay: true, }); }), + // An idle card receives no more publishes to clear its expired finished rows. + endIdleLiveActivities: Effect.gen(function* () { + const now = yield* DateTime.now; + const idleTargets = yield* liveActivities.listIdleArmedTargets({ + deliveredBefore: DateTime.formatIso( + DateTime.subtract(now, { milliseconds: TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS }), + ), + }); + yield* Effect.annotateCurrentSpan({ "relay.live_activities.idle_count": idleTargets.length }); + yield* Effect.forEach( + Object.entries(Arr.groupBy(idleTargets, (target) => target.user_id)), + ([userId, candidates]) => + Effect.gen(function* () { + const activeStates = yield* rows.listForUser({ userId }); + const aggregate = makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }); + if (aggregate !== null) return; + const targets = yield* liveActivities.listTargets({ userId }); + const idleDeviceIds = new Set(candidates.map((target) => target.device_id)); + yield* Effect.forEach( + targets.filter((target) => idleDeviceIds.has(target.device_id)), + (target) => + apnsDeliveries + .sendForTarget({ + target, + aggregate: null, + nowMs: now.epochMilliseconds, + replay: true, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to end idle Live Activity", { + deviceId: target.device_id, + error, + }), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to list idle Live Activity content or targets", { + userId, + error, + }), + ), + ), + { concurrency: 4, discard: true }, + ); + }).pipe(Effect.withSpan("relay.agent_activity_publisher.end_idle_live_activities")), publish: Effect.fn("relay.agent_activity_publisher.publish")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 9b2139bfaad1..abb77408843b 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -221,6 +221,7 @@ function layerFor(input: { Layer.succeed(LiveActivities.LiveActivities, { register: () => Effect.void, listTargets: () => Effect.succeed(input.currentTargets ?? [target]), + listIdleArmedTargets: () => Effect.succeed([]), markStartQueued: (queued) => Effect.sync(() => { input.queuedStarts?.push(queued); @@ -896,6 +897,64 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(layerFor({ attempts }))); }); + it.effect.each([ + { phase: "running", updatedAt: state.updatedAt, disabled: false, sends: false }, + { phase: "completed", updatedAt: state.updatedAt, disabled: false, sends: false }, + { phase: "completed", updatedAt: "1969-12-31T23:44:00.000Z", disabled: false, sends: true }, + { phase: "running", updatedAt: state.updatedAt, disabled: true, sends: true }, + ] as const)( + "rechecks a queued contentless end: $phase at $updatedAt, disabled=$disabled", + ({ phase, updatedAt, disabled, sends }) => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + const signedEnd = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload: makeApnsDeliveryJobPayload({ + kind: "live_activity_end", + userId: target.user_id, + deviceId: target.device_id, + token: "activity-token", + aggregate: null, + createdAt: state.updatedAt, + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-end-1", + }), + }); + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signedEnd); + expect(result.ok).toBe(true); + expect(requests).toHaveLength(sends ? 1 : 0); + if (sends) { + const request = requests[0]!; + expect(request.url).toBe("https://api.sandbox.push.apple.com/3/device/activity-token"); + expect(request.body._tag).toBe("Uint8Array"); + if (request.body._tag === "Uint8Array") { + const { aps } = JSON.parse(new TextDecoder().decode(request.body.body)); + expect(aps.event).toBe("end"); + expect(aps.alert).toBeUndefined(); + expect(aps.sound).toBeUndefined(); + } + } + }).pipe( + Effect.provide( + layerFor({ + attempts: [], + config: signingConfig, + currentTargets: [ + { ...target, preferences_json: disabled ? disabledPreferences : enabledPreferences }, + ], + activityStates: [{ ...state, phase, updatedAt }], + execute: (request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), + }), + ), + ); + }, + ); + it.effect("skips a queued start when the user no longer has live work", () => { const attempts: Array = []; const clearedStarts: Array< diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index b58b0022208b..390f2a8eb5d8 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -24,6 +24,7 @@ import { sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, } from "./agentActivityPayloads.ts"; +import { makeAggregateState } from "./agentActivityAggregate.ts"; import * as Apns from "./ApnsClient.ts"; import { ApnsDeliveryJobLiveActivityAggregateMissing, @@ -564,6 +565,26 @@ export const make = Effect.gen(function* () { ); }); + const userHasContentToShow = Effect.fnUntraced(function* (userId: string) { + const now = yield* DateTime.now; + return yield* activityRows.listForUser({ userId }).pipe( + Effect.map( + (activeStates) => + makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }) !== null, + ), + // Keep the card on persistence failures; a later sweep can retry. + Effect.catch((error) => + Effect.logWarning("Content recheck failed; keeping the card", { error }).pipe( + Effect.as(true), + ), + ), + ); + }); + const stateIdentityIsCurrent = Effect.fnUntraced(function* (input: { readonly userId: string; readonly environmentId: string; @@ -750,6 +771,20 @@ export const make = Effect.gen(function* () { }); return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } + // A contentless end must not retire a card that newer work now owns. An + // end for a device that turned Live Activities off still goes out. + if ( + input.kind === "live_activity_end" && + aggregate === null && + parsePreferences(currentTarget.preferences_json)?.liveActivitiesEnabled !== false && + (yield* userHasContentToShow(input.target.user_id)) + ) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + apnsReason: "Stale APNs end job skipped.", + }); + return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); + } } if ( input.kind === "live_activity_start" && diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index af52ba74e8c2..35aad9d80f7f 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -121,6 +121,7 @@ function harness() { Layer.succeed(LiveActivities.LiveActivities, { register: () => Effect.void, listTargets: () => Effect.sync(() => [current.target]), + listIdleArmedTargets: () => Effect.succeed([]), markDelivery: (input) => Effect.sync(() => { marked.push(input); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index eea8dc90f33c..b7fe1671e62e 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -13,7 +13,7 @@ import * as Effect from "effect/Effect"; import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNotNull, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; @@ -43,6 +43,18 @@ export class LiveActivityTargetListPersistenceError extends Schema.TaggedError()( + "LiveActivityIdleTargetListPersistenceError", + { + deliveredBefore: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to list idle Live Activities delivered before ${this.deliveredBefore}.`; + } +} + export class LiveActivityDeliveryMarkPersistenceError extends Schema.TaggedError()( "LiveActivityDeliveryMarkPersistenceError", { @@ -97,6 +109,12 @@ export class LiveActivities extends Context.Service< readonly listTargets: (input: { readonly userId: string; }) => Effect.Effect, LiveActivityTargetListPersistenceError>; + readonly listIdleArmedTargets: (input: { + readonly deliveredBefore: string; + }) => Effect.Effect< + ReadonlyArray<{ readonly user_id: string; readonly device_id: string }>, + LiveActivityIdleTargetListPersistenceError + >; readonly markDelivery: (input: { readonly userId: string; readonly deviceId: string; @@ -270,6 +288,32 @@ export const make = Effect.gen(function* () { ); }), + listIdleArmedTargets: Effect.fn("relay.live_activities.list_idle_armed_targets")( + function* (input) { + return yield* db + .select({ + user_id: relayLiveActivities.userId, + device_id: relayLiveActivities.deviceId, + }) + .from(relayLiveActivities) + .where( + and( + isNotNull(relayLiveActivities.activityPushToken), + sql`coalesce(${relayLiveActivities.lastLiveActivityDeliveryAt}, ${relayLiveActivities.remoteStartedAt}, ${relayLiveActivities.updatedAt}) < ${input.deliveredBefore}`, + ), + ) + .pipe( + Effect.mapError( + (cause) => + new LiveActivityIdleTargetListPersistenceError({ + deliveredBefore: input.deliveredBefore, + cause, + }), + ), + ); + }, + ), + markDelivery: Effect.fn("relay.live_activities.mark_delivery")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.deviceId, diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 43c5ad62871b..38523dd7581b 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -67,6 +67,7 @@ function makeLiveActivities( return { register: () => Effect.void, listTargets: () => Effect.succeed([]), + listIdleArmedTargets: () => Effect.succeed([]), markDelivery: () => Effect.void, markStartQueued: () => Effect.void, clearStartQueued: () => Effect.void, @@ -192,6 +193,7 @@ function makeAgentActivityPublisher( return { publish: () => Effect.succeed({ ok: true, deliveries: [] }), replayForLiveActivityRegistration: () => Effect.succeed(null), + endIdleLiveActivities: Effect.void, ...overrides, }; } diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 80070bff78bf..14f3d4d4cbc7 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -1275,6 +1275,7 @@ describe("relay routing fallback", () => { return { ok: true as const, deliveries: [] }; }), replayForLiveActivityRegistration: () => Effect.succeed(null), + endIdleLiveActivities: Effect.void, }); const layerSignatures = Layer.succeed( EnvironmentPublishSignatures.EnvironmentPublishSignatures, diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 29e5359fa3f6..7e381b23f7d2 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -414,8 +414,16 @@ export const layer = Api.make( : Effect.logWarning("Failed to clean up inactive managed tunnels", { cause }), ), ), + AgentActivityPublisher.AgentActivityPublisher.pipe( + Effect.flatMap((publisher) => publisher.endIdleLiveActivities), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to end idle Live Activities", { cause }), + ), + ), ], - { concurrency: 2, discard: true }, + { concurrency: 3, discard: true }, ).pipe( Effect.withSpan("relay.cron.prune_expired_state"), Observability.withSchemaErrorSpanAttributes, From af17eb93311e40abf5365a235a6f1983512d50dc Mon Sep 17 00:00:00 2001 From: Dylan Zahn Date: Mon, 19 Oct 2026 20:36:07 -0500 Subject: [PATCH 019/458] fix(mobile): resize Android image previews with the window (#13859) Co-authored-by: Julius Marminge --- .../react-native-image-viewing@0.2.2.patch | 274 ++++++++++++++++++ pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 1 + 3 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 patches/react-native-image-viewing@0.2.2.patch diff --git a/patches/react-native-image-viewing@0.2.2.patch b/patches/react-native-image-viewing@0.2.2.patch new file mode 100644 index 000000000000..6e2b719704e1 --- /dev/null +++ b/patches/react-native-image-viewing@0.2.2.patch @@ -0,0 +1,274 @@ +diff --git a/dist/ImageViewing.js b/dist/ImageViewing.js +index 3d7641a9a94864415d207c678b71f8c592c90aa1..2b9ef77c3eb58cd284152ad22b2e711a91d50e7d 100644 +--- a/dist/ImageViewing.js ++++ b/dist/ImageViewing.js +@@ -5,8 +5,8 @@ + * LICENSE file in the root directory of this source tree. + * + */ +-import React, { useCallback, useRef, useEffect } from "react"; +-import { Animated, Dimensions, StyleSheet, View, VirtualizedList, Modal, } from "react-native"; ++import React, { useCallback, useRef, useEffect, useLayoutEffect } from "react"; ++import { Animated, Dimensions, StyleSheet, View, VirtualizedList, Modal, Platform, useWindowDimensions, } from "react-native"; + import ImageItem from "./components/ImageItem/ImageItem"; + import ImageDefaultHeader from "./components/ImageDefaultHeader"; + import StatusBarManager from "./components/StatusBarManager"; +@@ -17,23 +17,34 @@ const DEFAULT_ANIMATION_TYPE = "fade"; + const DEFAULT_BG_COLOR = "#000"; + const DEFAULT_DELAY_LONG_PRESS = 800; + const SCREEN = Dimensions.get("screen"); +-const SCREEN_WIDTH = SCREEN.width; ++const useViewingDimensions = Platform.OS === "android" ? useWindowDimensions : () => SCREEN; + function ImageViewing({ images, keyExtractor, imageIndex, visible, onRequestClose, onLongPress = () => { }, onImageIndexChange, animationType = DEFAULT_ANIMATION_TYPE, backgroundColor = DEFAULT_BG_COLOR, presentationStyle, swipeToCloseEnabled, doubleTapToZoomEnabled, delayLongPress = DEFAULT_DELAY_LONG_PRESS, HeaderComponent, FooterComponent, }) { ++ const viewport = useViewingDimensions(); ++ const SCREEN_WIDTH = viewport.width; ++ const viewportKey = Platform.OS === "android" ? `${viewport.width}:${viewport.height}` : undefined; + const imageList = useRef(null); + const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose); +- const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN); +- const [headerTransform, footerTransform, toggleBarsVisible] = useAnimatedComponents(); ++ const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, viewport); ++ const [headerTransform, footerTransform, toggleBarsVisible] = useAnimatedComponents(viewportKey); + useEffect(() => { + if (onImageIndexChange) { + onImageIndexChange(currentImageIndex); + } + }, [currentImageIndex]); +- const onZoom = useCallback((isScaled) => { ++ const activeZoom = useRef(null); ++ const onZoom = useCallback(function handleZoom(isScaled) { ++ if (Platform.OS === "android" && activeZoom.current !== handleZoom) { ++ return; ++ } + var _a, _b; + // @ts-ignore + (_b = (_a = imageList) === null || _a === void 0 ? void 0 : _a.current) === null || _b === void 0 ? void 0 : _b.setNativeProps({ scrollEnabled: !isScaled }); + toggleBarsVisible(!isScaled); +- }, [imageList]); ++ }, [imageList, toggleBarsVisible]); ++ useLayoutEffect(() => { ++ activeZoom.current = onZoom; ++ return () => { activeZoom.current = null; }; ++ }, [onZoom]); + if (!visible) { + return null; + } +@@ -45,7 +56,8 @@ function ImageViewing({ images, keyExtractor, imageIndex, visible, onRequestClos + imageIndex: currentImageIndex, + })) : ()} + +- images[index]} getItemCount={() => images.length} getItemLayout={(_, index) => ({ ++ {/* Reset Android image gestures on resize while preserving custom header state. */} ++ images[index]} getItemCount={() => images.length} getItemLayout={(_, index) => ({ + length: SCREEN_WIDTH, + offset: SCREEN_WIDTH * index, + index, +diff --git a/dist/components/ImageItem/ImageItem.android.js b/dist/components/ImageItem/ImageItem.android.js +index 5fcaa9f9a7d893817fee89b85186ee3936118e69..3effe838fa54495d165b81cc7501e72d51fbee1b 100644 +--- a/dist/components/ImageItem/ImageItem.android.js ++++ b/dist/components/ImageItem/ImageItem.android.js +@@ -6,17 +6,17 @@ + * + */ + import React, { useCallback, useRef, useState } from "react"; +-import { Animated, ScrollView, Dimensions, StyleSheet, } from "react-native"; ++import { Animated, ScrollView, Dimensions, } from "react-native"; + import useImageDimensions from "../../hooks/useImageDimensions"; + import usePanResponder from "../../hooks/usePanResponder"; + import { getImageStyles, getImageTransform } from "../../utils"; + import { ImageLoading } from "./ImageLoading"; + const SWIPE_CLOSE_OFFSET = 75; + const SWIPE_CLOSE_VELOCITY = 1.75; +-const SCREEN = Dimensions.get("window"); +-const SCREEN_WIDTH = SCREEN.width; +-const SCREEN_HEIGHT = SCREEN.height; + const ImageItem = ({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPress, swipeToCloseEnabled = true, doubleTapToZoomEnabled = true, }) => { ++ const SCREEN = Dimensions.get("window"); ++ const SCREEN_WIDTH = SCREEN.width; ++ const SCREEN_HEIGHT = SCREEN.height; + const imageContainer = useRef(null); + const imageDimensions = useImageDimensions(imageSrc); + const [translate, scale] = getImageTransform(imageDimensions, SCREEN); +@@ -31,7 +31,7 @@ const ImageItem = ({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPre + scrollEnabled: !isZoomed, + }); + } +- }, [imageContainer]); ++ }, [imageContainer, onZoom]); + const onLongPressHandler = useCallback(() => { + onLongPress(imageSrc); + }, [imageSrc, onLongPress]); +@@ -64,7 +64,7 @@ const ImageItem = ({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPre + const offsetY = (_c = (_b = (_a = nativeEvent) === null || _a === void 0 ? void 0 : _a.contentOffset) === null || _b === void 0 ? void 0 : _b.y, (_c !== null && _c !== void 0 ? _c : 0)); + scrollValueY.setValue(offsetY); + }; +- return ( +@@ -72,13 +72,4 @@ const ImageItem = ({ imageSrc, onZoom, onRequestClose, onLongPress, delayLongPre + {(!isLoaded || !imageDimensions) && } + ); + }; +-const styles = StyleSheet.create({ +- listItem: { +- width: SCREEN_WIDTH, +- height: SCREEN_HEIGHT, +- }, +- imageScrollContainer: { +- height: SCREEN_HEIGHT * 2, +- }, +-}); + export default React.memo(ImageItem); +diff --git a/dist/components/ImageItem/ImageLoading.js b/dist/components/ImageItem/ImageLoading.js +index 9770f7db158a2fb2b1c69a49e5fcbc89d8624603..995fab7524bb8673258d44e5eda361c62dae0324 100644 +--- a/dist/components/ImageItem/ImageLoading.js ++++ b/dist/components/ImageItem/ImageLoading.js +@@ -6,25 +6,17 @@ + * + */ + import React from "react"; +-import { ActivityIndicator, Dimensions, StyleSheet, View } from "react-native"; ++import { ActivityIndicator, Dimensions, Platform, StyleSheet, View } from "react-native"; + const SCREEN = Dimensions.get("screen"); +-const SCREEN_WIDTH = SCREEN.width; +-const SCREEN_HEIGHT = SCREEN.height; +-export const ImageLoading = () => ( ++export const ImageLoading = () => { ++ const { width, height } = Platform.OS === "android" ? Dimensions.get("window") : SCREEN; ++ return ( + + ); ++}; + const styles = StyleSheet.create({ +- listItem: { +- width: SCREEN_WIDTH, +- height: SCREEN_HEIGHT, +- }, + loading: { +- width: SCREEN_WIDTH, +- height: SCREEN_HEIGHT, + alignItems: "center", + justifyContent: "center", + }, +- imageScrollContainer: { +- height: SCREEN_HEIGHT, +- }, + }); +diff --git a/dist/hooks/useAnimatedComponents.d.ts b/dist/hooks/useAnimatedComponents.d.ts +index de5957458fea62f33743103acaa170a2c90d3638..dbafa8c5c5f3f86161e8520822cf10e19e88ea25 100644 +--- a/dist/hooks/useAnimatedComponents.d.ts ++++ b/dist/hooks/useAnimatedComponents.d.ts +@@ -6,7 +6,7 @@ + * + */ + import { Animated } from "react-native"; +-declare const useAnimatedComponents: () => readonly [{ ++declare const useAnimatedComponents: (resetKey?: string) => readonly [{ + [key: string]: Animated.Value; + }[], { + [key: string]: Animated.Value; +diff --git a/dist/hooks/useAnimatedComponents.js b/dist/hooks/useAnimatedComponents.js +index 4730fe40735bd80afc97abdd14150a2fb4abf3e5..0ed2100744ccc9bcee7f1b15b0328a729d47bb16 100644 +--- a/dist/hooks/useAnimatedComponents.js ++++ b/dist/hooks/useAnimatedComponents.js +@@ -5,16 +5,19 @@ + * LICENSE file in the root directory of this source tree. + * + */ ++import { useCallback, useMemo } from "react"; + import { Animated } from "react-native"; + const INITIAL_POSITION = { x: 0, y: 0 }; + const ANIMATION_CONFIG = { + duration: 200, + useNativeDriver: true, + }; +-const useAnimatedComponents = () => { +- const headerTranslate = new Animated.ValueXY(INITIAL_POSITION); +- const footerTranslate = new Animated.ValueXY(INITIAL_POSITION); +- const toggleVisible = (isVisible) => { ++const useAnimatedComponents = (resetKey) => { ++ const [headerTranslate, footerTranslate] = useMemo(() => [ ++ new Animated.ValueXY(INITIAL_POSITION), ++ new Animated.ValueXY(INITIAL_POSITION), ++ ], [resetKey]); ++ const toggleVisible = useCallback((isVisible) => { + if (isVisible) { + Animated.parallel([ + Animated.timing(headerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 }), +@@ -33,7 +36,7 @@ const useAnimatedComponents = () => { + }), + ]).start(); + } +- }; ++ }, [headerTranslate, footerTranslate]); + const headerTransform = headerTranslate.getTranslateTransform(); + const footerTransform = footerTranslate.getTranslateTransform(); + return [headerTransform, footerTransform, toggleVisible]; +diff --git a/dist/hooks/useImageIndexChange.js b/dist/hooks/useImageIndexChange.js +index cb5f0f798e5cfeedf459b7d22e92871db3092020..97ca2c9c326b1c53e6a5d90c3bb95d53342088d1 100644 +--- a/dist/hooks/useImageIndexChange.js ++++ b/dist/hooks/useImageIndexChange.js +@@ -5,16 +5,34 @@ + * LICENSE file in the root directory of this source tree. + * + */ +-import { useState } from "react"; ++import { useCallback, useLayoutEffect, useRef, useState } from "react"; ++import { Platform } from "react-native"; + const useImageIndexChange = (imageIndex, screen) => { + const [currentImageIndex, setImageIndex] = useState(imageIndex); +- const onScroll = (event) => { ++ const indexRef = useRef(imageIndex); ++ const activeScroll = useRef(null); ++ const onScroll = useCallback(function handleScroll(event) { ++ if (Platform.OS === "android") { ++ // Events at this width stay valid until commit; replaced or relaid-out lists do not. ++ if (activeScroll.current !== handleScroll || ++ Math.abs(event.nativeEvent.layoutMeasurement.width - screen.width) > 1) { ++ return; ++ } ++ } + const { nativeEvent: { contentOffset: { x: scrollX }, }, } = event; + if (screen.width) { + const nextIndex = Math.round(scrollX / screen.width); +- setImageIndex(nextIndex < 0 ? 0 : nextIndex); ++ const boundedIndex = nextIndex < 0 ? 0 : nextIndex; ++ if (boundedIndex !== indexRef.current) { ++ indexRef.current = boundedIndex; ++ setImageIndex(boundedIndex); ++ } + } +- }; ++ }, [screen]); ++ useLayoutEffect(() => { ++ activeScroll.current = onScroll; ++ return () => { activeScroll.current = null; }; ++ }, [onScroll]); + return [currentImageIndex, onScroll]; + }; + export default useImageIndexChange; +diff --git a/dist/hooks/usePanResponder.js b/dist/hooks/usePanResponder.js +index 99dcbc7efea4eba86a8bc2bad71dde73c8ebf1b4..3a374c0923879548ec3826a78be16f79c6e01432 100644 +--- a/dist/hooks/usePanResponder.js ++++ b/dist/hooks/usePanResponder.js +@@ -8,14 +8,14 @@ + import { useMemo, useEffect } from "react"; + import { Animated, Dimensions, } from "react-native"; + import { createPanResponder, getDistanceBetweenTouches, getImageTranslate, getImageDimensionsByTranslate, } from "../utils"; +-const SCREEN = Dimensions.get("window"); +-const SCREEN_WIDTH = SCREEN.width; +-const SCREEN_HEIGHT = SCREEN.height; +-const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT); + const SCALE_MAX = 2; + const DOUBLE_TAP_DELAY = 300; + const OUT_BOUND_MULTIPLIER = 0.75; + const usePanResponder = ({ initialScale, initialTranslate, onZoom, doubleTapToZoomEnabled, onLongPress, delayLongPress, }) => { ++ const SCREEN = Dimensions.get("window"); ++ const SCREEN_WIDTH = SCREEN.width; ++ const SCREEN_HEIGHT = SCREEN.height; ++ const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT); + let numberInitialTouches = 1; + let initialTouches = []; + let currentScale = initialScale; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a8e06b7722e..62bd52bbbb2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,7 @@ patchedDependencies: micromark-extension-llm-math@3.1.0: 49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9 node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 + react-native-image-viewing@0.2.2: a479085493f2404ecdefea18f46c6be3ad49cfb54da8e78dc7746651263cb5a0 react-native-keyboard-controller@1.22.7: f28d08f1233e3cce8b9324b4357d2b10f1ee040b8051bc87235b0fb95069b9bb react-native-pdf-jsi@5.0.0: 6f52854210f61bef499850ebd498229a111bc34ec5b2a4925e644f2f48f42760 react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 @@ -569,7 +570,7 @@ importers: version: 3.3.0(patch_hash=abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 0.2.2(patch_hash=a479085493f2404ecdefea18f46c6be3ad49cfb54da8e78dc7746651263cb5a0)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-keyboard-controller: specifier: 1.22.7 version: 1.22.7(patch_hash=f28d08f1233e3cce8b9324b4357d2b10f1ee040b8051bc87235b0fb95069b9bb)(react-native-reanimated@4.7.1(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -22590,7 +22591,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-image-viewing@0.2.2(patch_hash=a479085493f2404ecdefea18f46c6be3ad49cfb54da8e78dc7746651263cb5a0)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dbe0c9dedf0d..73827916a40b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -317,6 +317,7 @@ packageExtensions: vite-plus: "catalog:" patchedDependencies: + react-native-image-viewing@0.2.2: patches/react-native-image-viewing@0.2.2.patch mdast-util-math@3.0.0: patches/mdast-util-math@3.0.0.patch micromark-extension-llm-math@3.1.0: patches/micromark-extension-llm-math@3.1.0.patch micromark-extension-gfm-table@2.1.1: patches/micromark-extension-gfm-table@2.1.1.patch From e85539ab59f13bafd156f831d2e6690df67eb677 Mon Sep 17 00:00:00 2001 From: tachytelicdetonation <47115683+tachytelicdetonation@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:36:12 -0500 Subject: [PATCH 020/458] fix(server): keep helper processes from outliving the backend (#12513) Co-authored-by: Claude Fable 5.1 Co-authored-by: Julius Marminge --- apps/server/src/processRunner.test.ts | 124 ++++++++++++++++++ apps/server/src/processRunner.ts | 3 + apps/server/src/testUtils/processProbe.ts | 73 +++++++++++ .../ClaudeTextGeneration.test.ts | 38 +++++- .../textGeneration/ClaudeTextGeneration.ts | 1 + .../CodexTextGeneration.test.ts | 44 ++++++- .../src/textGeneration/CodexTextGeneration.ts | 2 + 7 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/testUtils/processProbe.ts diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 13e811baedfd..eb376ee5cedd 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,18 +1,24 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ProcessRunner from "./processRunner.ts"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; +import { makeProcessProbe, processProbeSource } from "./testUtils/processProbe.ts"; type ChildProcessCommand = { readonly command: string; @@ -398,6 +404,124 @@ describe("runProcess", () => { }), ); + it.effect("kills a helper that ignores SIGTERM when its run times out", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-process-runner-" }); + const probe = yield* makeProcessProbe; + const helper = writeFakeCli({ + directory, + name: "stubborn", + source: `process.on("SIGTERM", () => {}); ${processProbeSource(probe.port)}`, + }); + const runner = yield* ProcessRunner.ProcessRunner; + const fiber = yield* runner + .run({ command: helper, args: [], timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkScoped); + yield* probe.started; + + yield* TestClock.adjust("1 second"); + yield* probe.exited; + expect((yield* Fiber.join(fiber))._tag).toBe("ProcessTimeoutError"); + }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))), + ); + + for (const wrapperExit of ["timeout", "success"] as const) { + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + `terminates descendants after wrapper ${wrapperExit}`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-process-tree-" }); + const probe = yield* makeProcessProbe; + const helper = writeFakeCli({ + directory, + name: "wrapper", + source: ` + import { spawn } from "node:child_process"; + process.on("SIGTERM", () => {}); + const child = spawn(process.execPath, ["--input-type=module", "-e", ${JSON.stringify(` + ${processProbeSource(probe.port)} + probe.once("connect", () => process.send("ready")); + `)}], { stdio: ["ignore", "ignore", "ignore", "ipc"] }); + ${wrapperExit === "success" ? 'child.once("message", () => process.exit(0));' : 'child.on("exit", () => process.exit(0));'} + `, + }); + const runner = yield* ProcessRunner.ProcessRunner; + const fiber = yield* runner + .run({ command: helper, args: [], timeout: "1 second" }) + .pipe(Effect.result, Effect.forkScoped); + yield* probe.started; + + if (wrapperExit === "timeout") yield* TestClock.adjust("1 second"); + yield* probe.exited; + const result = yield* Fiber.join(fiber); + if (wrapperExit === "timeout") { + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "ProcessTimeoutError" }, + }); + } else { + expect(result).toMatchObject({ + _tag: "Success", + success: { code: 0, timedOut: false }, + }); + } + }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))), + ); + } + + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "exits the backend and terminates stubborn helpers on graceful group shutdown", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-process-backend-" }); + const probe = yield* makeProcessProbe; + const helper = writeFakeCli({ + directory, + name: "helper", + source: `process.on("SIGTERM", () => {}); ${processProbeSource(probe.port)}`, + }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const backend = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "--experimental-strip-types", + "--input-type=module", + "-e", + ` + import * as Effect from "effect/Effect"; + import * as Layer from "effect/Layer"; + import * as NodeServices from "@effect/platform-node/NodeServices"; + import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; + import * as ProcessRunner from ${JSON.stringify(new URL("./processRunner.ts", import.meta.url).href)}; + NodeRuntime.runMain(Effect.gen(function* () { + const runner = yield* ProcessRunner.ProcessRunner; + yield* runner.run({ command: ${JSON.stringify(helper)}, args: [] }); + }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer))))); + `, + ], + { + detached: true, + forceKillAfter: "1 second", + cwd: yield* path.fromFileUrl(new URL("..", import.meta.url)), + }, + ), + ); + yield* probe.started; + + // Desktop allows five seconds for NodeRuntime to finish scope finalizers. + process.kill(-backend.pid, "SIGTERM"); + yield* Effect.all( + [backend.exitCode.pipe(Effect.timeout("4 seconds"), TestClock.withLive), probe.exited], + { concurrency: "unbounded" }, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("returns a synthetic timed out result when timeoutBehavior is timedOutResult", () => Effect.gen(function* () { const spawner = makeSpawner(() => diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 3ee8232688cb..002aebcac9c4 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -146,6 +146,8 @@ export class ProcessRunner extends Context.Service< >()("t3/processRunner") {} const DEFAULT_TIMEOUT = "60 seconds"; +// Keep SIGTERM-ignoring helpers from blocking timeouts or graceful shutdown. +const FORCE_KILL_AFTER = "1 second"; const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; @@ -304,6 +306,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } : {}), shell: spawnCommand.shell, + forceKillAfter: FORCE_KILL_AFTER, }), ) .pipe( diff --git a/apps/server/src/testUtils/processProbe.ts b/apps/server/src/testUtils/processProbe.ts new file mode 100644 index 000000000000..9970359244be --- /dev/null +++ b/apps/server/src/testUtils/processProbe.ts @@ -0,0 +1,73 @@ +// @effect-diagnostics nodeBuiltinImport:off - a socket observes fixture processes after their parent exits. +import * as NodeNet from "node:net"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +export const makeProcessProbe = Effect.gen(function* () { + const started = yield* Deferred.make(); + const exited = yield* Deferred.make(); + const sockets = new Set(); + const server = NodeNet.createServer((socket) => { + sockets.add(socket); + let output = ""; + socket.on("data", (chunk) => { + output += chunk.toString(); + if (output.includes("\n")) { + const pid = Number(output.trim()); + if (Number.isSafeInteger(pid) && pid > 0) { + Deferred.doneUnsafe(started, Effect.succeed(pid)); + } + } + }); + socket.on("close", () => { + sockets.delete(socket); + Deferred.doneUnsafe(exited, Effect.void); + }); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.sync(() => { + for (const socket of sockets) socket.destroy(); + server.close(); + }), + ); + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* Effect.die("Missing process probe port"); + } + return { + port: address.port, + started: Deferred.await(started).pipe( + Effect.timeout("4 seconds"), + TestClock.withLive, + Effect.tap((pid) => + Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* Deferred.isDone(exited)) return; + // Only a PID reported by this fixture is eligible for failure cleanup. + yield* Effect.sync(() => { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + }); + }), + ), + ), + ), + exited: Deferred.await(exited).pipe(Effect.timeout("4 seconds"), TestClock.withLive), + }; +}); + +export const processProbeSource = (port: number) => ` + const { createConnection } = await import("node:net"); + const probe = createConnection(${port}, "127.0.0.1", () => { + probe.write(process.pid + "\\n"); + }); +`; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index f2b94aeb933e..6d8ee7d7a314 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -4,13 +4,16 @@ import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; +import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts"; import { SYNTHETIC_CLAUDE_CAPABLE_MODEL, SYNTHETIC_CLAUDE_COLLIDING_ALIAS, @@ -31,7 +34,7 @@ const layerClaudeTextGenerationTest = ServerConfig.ServerConfig.layerTest(proces // The stub behaviour lives in Node so the same implementation runs on Windows, // where a shebang file is not executable and would fall through to the real // Claude CLI on PATH; `writeFakeCli` picks the launcher shape per host. -function makeFakeClaudeBinary(dir: string) { +function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) { return Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -45,7 +48,10 @@ function makeFakeClaudeBinary(dir: string) { directory: binDir, name: "claude", platform, - source, + source: + lifecyclePort === undefined + ? source + : `process.on("SIGTERM", () => {}); ${processProbeSource(lifecyclePort)}`, }); return binDir; }); @@ -54,6 +60,7 @@ function makeFakeClaudeBinary(dir: string) { function withFakeClaudeEnv( input: { output: string; + lifecyclePort?: number; exitCode?: number; stderr?: string; argsMustContain?: string; @@ -68,7 +75,7 @@ function withFakeClaudeEnv( return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); - const binDir = yield* makeFakeClaudeBinary(tempDir); + const binDir = yield* makeFakeClaudeBinary(tempDir, input.lifecyclePort); const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":"; const previousPath = process.env.PATH; const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; @@ -192,6 +199,31 @@ function withFakeClaudeEnv( } it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { + it.effect("kills a stalled CLI when the request times out", () => + Effect.gen(function* () { + const probe = yield* makeProcessProbe; + yield* withFakeClaudeEnv({ output: "", lifecyclePort: probe.port }, (textGeneration) => + Effect.gen(function* () { + const fiber = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* probe.started; + + yield* TestClock.adjust("180 seconds"); + yield* probe.exited; + expect((yield* Fiber.join(fiber)).detail).toBe("Claude CLI request timed out."); + }), + ); + }), + ); + it.effect("forwards Claude thinking settings without passing unsupported effort", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8da15d127b5e..4e1df3067d98 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -234,6 +234,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, + forceKillAfter: "1 second", }); const child = yield* commandSpawner diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 7eab8ee7472d..29ae74e97c04 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -14,6 +16,7 @@ import { expect } from "vite-plus/test"; import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; @@ -30,6 +33,7 @@ const layerCodexTextGenerationTest = ServerConfig.ServerConfig.layerTest(process interface FakeCodexInput { output: string; + lifecycle?: { port: number; command: "exec" | "mcp list" }; exitCode?: number; stderr?: string; requireImage?: boolean; @@ -67,7 +71,17 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { 'import * as NodeFS from "node:fs";', `const check = ${check};`, "const args = process.argv.slice(2);", - 'if (args.includes("mcp") && args.includes("list")) {', + 'const isMcpList = args.includes("mcp") && args.includes("list");', + ...(input.lifecycle === undefined + ? [] + : [ + `if (isMcpList === ${input.lifecycle.command === "mcp list"}) {`, + 'process.on("SIGTERM", () => {});', + processProbeSource(input.lifecycle.port), + "await new Promise(() => {});", + "}", + ]), + "if (isMcpList) {", ' if (typeof check.mcpServers === "number") process.exit(check.mcpServers);', " process.stdout.write(JSON.stringify(check.mcpServers));", " process.exit(0);", @@ -173,6 +187,34 @@ function withFakeCodexEnv( } it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { + it.effect.each(["exec", "mcp list"] as const)( + "kills a stalled %s CLI when its request is interrupted", + (command) => + Effect.gen(function* () { + const probe = yield* makeProcessProbe; + yield* withFakeCodexEnv( + { output: "", lifecycle: { port: probe.port, command } }, + (textGeneration) => + Effect.gen(function* () { + const fiber = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.forkScoped); + yield* probe.started; + + const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkScoped); + yield* probe.exited; + yield* Fiber.join(interrupted); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + }), + ); + }), + ); + it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])( "dispatches the qualified live model for %s", (selectedModel) => diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6ee03420b7ab..20066232105f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -178,6 +178,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func env: input.env, cwd: input.cwd, shell: spawnCommand.shell, + forceKillAfter: "1 second", }), ); const [stdout, , exitCode] = yield* Effect.all( @@ -319,6 +320,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, + forceKillAfter: "1 second", }); const child = yield* commandSpawner From d1c70c022d694959e23215d7e79128a85f167195 Mon Sep 17 00:00:00 2001 From: nekohasekai Date: Tue, 20 Oct 2026 09:56:23 +0800 Subject: [PATCH 021/458] fix(mobile): open pending tasks from the iPad sidebar (#15083) Co-authored-by: Julius Marminge --- .../mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx | 3 +++ .../src/features/threads/ThreadNavigationSidebar.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 40b0e6e69696..669537b6fae6 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -63,6 +63,7 @@ import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; import { HomeListOptionsProvider } from "../home/home-list-options"; +import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -449,6 +450,7 @@ function AdaptiveWorkspaceLayoutContent( }; const handleOpenThreadPullRequests = useOpenThreadPullRequests(); + const { openPendingTask: handleOpenPendingTask } = usePendingTaskListActions(); const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { navigation.navigate("NewTaskSheet", { @@ -715,6 +717,7 @@ function AdaptiveWorkspaceLayoutContent( onOpenEnvironmentSettings={handleOpenEnvironmentSettings} onNewThreadInProject={handleNewThreadInProject} onNewThreadOnBranch={handleNewThreadOnBranch} + onOpenPendingTask={handleOpenPendingTask} onSelectThread={handleSelectThread} onOpenThreadPullRequests={handleOpenThreadPullRequests} onSearchQueryChange={setPrimarySidebarSearchQuery} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index a588120b0dc4..1cd870b006bc 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -33,7 +33,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; -import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; +import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -92,6 +92,7 @@ interface ThreadNavigationSidebarProps { readonly onOpenEnvironmentSettings: () => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; + readonly onOpenPendingTask: (pendingTask: PendingNewTask) => void; readonly onSearchQueryChange: (query: string) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onOpenThreadPullRequests: ( @@ -155,7 +156,7 @@ function ThreadNavigationSidebarPane( } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const queuedThreadKeys = useQueuedThreadKeys(); - const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); + const { confirmDeletePendingTask } = usePendingTaskListActions(); const environments = Object.values(savedConnectionsById) .map((connection) => ({ environmentId: connection.environmentId, @@ -581,7 +582,7 @@ function ThreadNavigationSidebarPane( environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} pane="sidebar" showPendingDivider={item.showPendingDivider} - onSelectPendingTask={openPendingTask} + onSelectPendingTask={props.onOpenPendingTask} onDeletePendingTask={confirmDeletePendingTask} /> ); From ea493ec1d8888cdc8d47278d0fcaebdfe9315ab0 Mon Sep 17 00:00:00 2001 From: Jyrki De Neve Date: Tue, 20 Oct 2026 04:56:28 +0300 Subject: [PATCH 022/458] fix(web): composer list markers no longer clip in wider prompt fonts (#18972) Co-authored-by: Julius Marminge --- apps/web/src/composer-rich-text-doc.ts | 12 +++++++- apps/web/src/index.css | 42 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index af7fd8c566d1..bef884c18629 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -262,8 +262,18 @@ const ComposerListItemExtension = ListItem.extend({ Delete: () => deleteAcrossList(this.editor), }; }, + // ProseMirror can reuse the parent list when only an item's marker changes. + // Item metadata lets CSS resize its gutter through CommonMark's nine-digit limit. renderHTML({ node, HTMLAttributes }) { - return ["li", mergeAttributes(HTMLAttributes, { "data-marker": node.attrs.marker }), 0]; + const marker = String(node.attrs.marker); + return [ + "li", + mergeAttributes(HTMLAttributes, { + "data-marker": marker, + "data-marker-width": Math.min(marker.length, 10), + }), + 0, + ]; }, }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 76ad40373067..c88c04a327d9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1869,6 +1869,48 @@ code { content: attr(data-marker) " "; } +/* The prompt font and size can differ from the interface, so its gutter must + also follow the prompt's font metrics. */ +.composer-tiptap ol { + --list-gutter: max(1.25rem, 3ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="3"]) { + --list-gutter: max(1.25rem, 4ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="4"]) { + --list-gutter: max(1.25rem, 5ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="5"]) { + --list-gutter: max(1.25rem, 6ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="6"]) { + --list-gutter: max(1.25rem, 7ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="7"]) { + --list-gutter: max(1.25rem, 8ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="8"]) { + --list-gutter: max(1.25rem, 9ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="9"]) { + --list-gutter: max(1.25rem, 10ch); +} + +.composer-tiptap ol:has(> li[data-marker-width="10"]) { + --list-gutter: max(1.25rem, 11ch); +} + +.composer-tiptap ul { + padding-left: max(1.25rem, 2ch); +} + :is(.chat-markdown, .composer-tiptap) ul ul { list-style-type: circle; } From 404c6e712e3f6a548e301abd2c9817364c656299 Mon Sep 17 00:00:00 2001 From: alcpereira <48070464+alcpereira@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:57:23 +0100 Subject: [PATCH 023/458] fix(server): settling a finished thread removes it from the Live Activity (#18604) Co-authored-by: Cursor Co-authored-by: Julius Marminge --- .../src/relay/AgentAwarenessRelay.test.ts | 111 ++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 3 +- packages/shared/src/agentAwareness.test.ts | 2 + packages/shared/src/agentAwareness.ts | 9 ++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 8811bd3d6d8a..3ae05f309ad8 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -11,6 +11,7 @@ import { ProjectId, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, TurnItemId, } from "@t3tools/contracts"; @@ -771,6 +772,116 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect.each(["completed", "failed"] as const)( + "settling a %s thread publishes a tombstone without re-alerting on unsettle", + (status) => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const pulls = yield* Queue.unbounded(); + const { relay, currentShell, publications } = yield* makeTestRelay({ + domainEvents: Stream.fromEffectRepeat( + Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), + ), + }); + yield* relay.start(); + yield* Queue.take(pulls); + const finished = shell({ + status, + latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), + }); + yield* Ref.set(currentShell, finished); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map(({ state }) => state?.phase), + [status], + ); + + for (const type of ["thread.settled", "thread.unsettled"] as const) { + const updated = { + ...finished, + settledOverride: type === "thread.settled" ? ("settled" as const) : ("active" as const), + updatedAt: yield* DateTime.now, + }; + yield* Ref.set(currentShell, updated); + yield* Queue.offer(events, { + id: EventId.make(type), + type, + threadId: THREAD_ID, + occurredAt: updated.updatedAt, + payload: { ...updated, lastVisitedAt: updated.lastVisitedAt ?? null }, + }); + yield* Queue.take(pulls); + yield* relay.drain; + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map(({ state }) => state?.phase ?? null), + [status, null], + ); + } + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { work: "running", status: "running", phase: "running" }, + { work: "user input", status: "completed", phase: "waiting_for_input" }, + { work: "monitor", status: "completed", phase: "running" }, + { work: "monitor", status: "failed", phase: "failed" }, + ] as const)( + "keeps settled $work visible until it finishes ($status run)", + ({ work, status, phase }) => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + const active = shell({ + status, + latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), + pendingRuntimeRequest: + work === "user input" + ? { + id: RuntimeRequestId.make("waiting"), + kind: "user_input", + createdAt: yield* DateTime.now, + } + : null, + pendingBackgroundTasks: work === "monitor" ? [{ taskId: "watch", kind: "monitor" }] : [], + }); + yield* Ref.set(currentShell, active); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map(({ state }) => state?.phase), + [phase], + ); + + yield* Ref.set(currentShell, { ...active, settledOverride: "settled" }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map(({ state }) => state?.phase), + [phase], + ); + + yield* Ref.set(currentShell, { + ...active, + settledOverride: "settled", + status: status === "running" ? "completed" : status, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map(({ state }) => state?.phase ?? null), + [phase, null], + ); + }), + ); + it.effect("confirms a first completed state and respects disabling during confirmation", () => Effect.gen(function* () { const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 87ce4afb08c8..6fdad0ced383 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -93,6 +93,7 @@ export function shouldPublishAgentAwarenessEvent( case "thread.metadata-updated": case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.settled": case "thread.model-selection-updated": case "thread.provider-switched": case "run.created": @@ -101,7 +102,7 @@ export function shouldPublishAgentAwarenessEvent( case "subagent.updated": case "provider-thread.updated": return true; - case "thread.settled": + // Unsettling must not re-alert a dismissed result. case "thread.unsettled": case "thread.snoozed": case "thread.unsnoozed": diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 85b8d614150b..e325fa263e9e 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -28,6 +28,7 @@ describe("projectThreadAwarenessV2", () => { | "pendingBackgroundTasks" | "pendingRuntimeRequest" | "lineage" + | "settledOverride" > > = {}, ) => ({ @@ -41,6 +42,7 @@ describe("projectThreadAwarenessV2", () => { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, status: "running" as const, pendingRuntimeRequest: null, + settledOverride: null, updatedAt, ...overrides, }); diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index b8f6a194a9d0..1c22deb9ff59 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -48,6 +48,7 @@ export interface ProjectThreadAwarenessV2Input { | "modelSelection" | "pendingBackgroundTasks" | "pendingRuntimeRequest" + | "settledOverride" | "status" | "title" | "updatedAt" @@ -63,6 +64,14 @@ export function projectThreadAwarenessV2( if (phase === null) { return null; } + // Resolve the phase first so live or waiting work stays visible after settlement. + if ( + thread.settledOverride === "settled" && + (phase === "completed" || phase === "failed") && + !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) + ) { + return null; + } const detail = phase === "completed" ? "Review the completed task." From fe4ebc7048aaba074e8b7f49295b63e8d091be81 Mon Sep 17 00:00:00 2001 From: Satyal Yadav <133562158+satyalyadav@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:57:27 -0700 Subject: [PATCH 024/458] fix(opencode): verify updates using the installed CLI version (#18561) Co-authored-by: Julius Marminge --- .../providerMaintenanceRunner.test.ts | 108 ++++++++++++++++++ .../src/provider/providerMaintenanceRunner.ts | 13 ++- packages/contracts/src/server.test.ts | 13 +++ packages/contracts/src/server.ts | 3 + .../src/server/maintenanceResolver.test.ts | 10 +- .../src/server/maintenanceResolver.ts | 8 +- .../src/server/status.test.ts | 44 +++++++ .../provider-opencode/src/server/status.ts | 88 +++++++------- 8 files changed, 234 insertions(+), 53 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index c33802fe252e..a35ea8213289 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -28,6 +28,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { + enrichProviderSnapshotWithVersionAdvisory, makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, } from "@t3tools/provider-core/server/maintenanceResolver"; @@ -1016,6 +1017,113 @@ describe("providerMaintenanceRunner", () => { }); }); +it.effect.each([ + { + mode: "default", + version: "1.14.19", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, + { + mode: "targeted", + version: "1.14.19", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, + { + mode: "unknown", + version: "1.14.20", + installation: { installedVersion: null }, + status: "unchanged", + }, + { mode: "legacy", version: "1.14.20", installation: {}, status: "succeeded" }, +] as const)( + "verifies the $mode OpenCode update using the refreshed installation", + ({ mode, version, installation, status }) => { + const calls: string[] = []; + const capabilities = lifecycleFor(OPENCODE_DRIVER); + const pinnedCapabilities = { + ...capabilities, + update: { ...capabilities.update!, lockKey: "npm-global:/fixture" }, + }; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: OPENCODE_DRIVER, + t3CodeRange: ">=0.0.42", + recommendedVersion: "1.14.20", + ranges: [{ range: "=1.14.20", status: "supported" }], + }, + ], + }; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry({ ...baseOpenCodeProvider, version }); + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), + refreshInstance: () => + Effect.gen(function* () { + assert.strictEqual(calls.length, 1); + const refreshed = yield* enrichProviderSnapshotWithVersionAdvisory( + { ...baseOpenCodeProvider, version, ...installation }, + pinnedCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService( + ProviderLatestVersions.ProviderLatestVersions, + latestVersions, + ), + ); + return yield* Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ ...provider, ...refreshed })), + ); + }), + }, + manifest, + ); + + const result = yield* updater.updateProvider({ + provider: OPENCODE_DRIVER, + ...(mode === "default" ? {} : { targetVersion: "1.14.20" }), + }); + + assert.deepStrictEqual(calls, [ + `npm install -g opencode-ai@${mode === "default" ? "latest" : "1.14.20"}`, + ]); + const provider = result.providers[0]; + assert.ok(provider); + assert.strictEqual(provider.updateState?.status, status); + assert.strictEqual(provider.version, version); + assert.strictEqual("installedVersion" in provider, mode !== "legacy"); + assert.strictEqual( + provider.versionAdvisory?.status, + mode === "unknown" ? "unknown" : "current", + ); + assert.strictEqual( + provider.versionAdvisory?.currentVersion, + mode === "unknown" ? null : "1.14.20", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + ProviderLatestVersions.layer, + layerLatestVersionHttpClient("1.14.20"), + layerMockSpawner((command, args) => { + calls.push([command, ...args].join(" ")); + return { stdout: "installed" }; + }), + ), + ), + ); + }, +); + it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { const calls: string[] = []; const manifest: ModelManifest.ModelManifestData = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 481d66d7e11d..fc5bc85dea64 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -519,12 +519,17 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || - verifiedProviders.some( - (verifiedProvider) => + verifiedProviders.some((verifiedProvider) => { + const installedVersion = + verifiedProvider.installedVersion !== undefined + ? verifiedProvider.installedVersion + : verifiedProvider.version; + return ( !isStillInstalled(verifiedProvider) || (targetVersion !== undefined && - verifiedProvider.version?.replace(/^v/, "") !== targetVersion), - ); + installedVersion?.replace(/^v/, "") !== targetVersion) + ); + }); const stillOutdated = targetVersion === undefined && verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 9051fe6ff6a7..2625777a1ba6 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -32,6 +32,19 @@ const baseProviderSnapshot = { }; describe("ServerProvider", () => { + it("preserves installed-version presence through the provider wire codec", () => { + const encode = Schema.encodeSync(ServerProvider); + for (const installation of [{}, { installedVersion: null }, { installedVersion: "1.2.3" }]) { + const decoded = decodeServerProvider({ ...baseProviderSnapshot, ...installation }); + const wire = JSON.parse(JSON.stringify(encode(decoded))); + expect(wire).toMatchObject({ version: "1.0.0", ...installation }); + expect(Object.hasOwn(wire, "installedVersion")).toBe( + Object.hasOwn(installation, "installedVersion"), + ); + expect(decodeServerProvider(wire)).toEqual(decoded); + } + }); + it.each([undefined, true, false])("decodes workspace command discovery pending=%s", (pending) => { const workspace = { cwd: "/workspace/project", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a66279618548..06719e9e16f2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -257,6 +257,9 @@ export const ServerProvider = Schema.Struct({ enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), + // Version reported by the installed CLI for a local provider with a separate + // server. Null means unknown; absence keeps legacy snapshots on `version`. + installedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), status: ServerProviderState, auth: ServerProviderAuth, checkedAt: IsoDateTime, diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 3f4db2dbd600..66b9ded4f32e 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -161,9 +161,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ); it.effect("does not fetch latest provider versions when update checks are disabled", () => - enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, manualPackageTool, { - enableProviderUpdateChecks: false, - }).pipe( + enrichProviderSnapshotWithVersionAdvisory( + { ...installedPackageToolProvider, installedVersion: "1.1.0" }, + manualPackageTool, + { enableProviderUpdateChecks: false }, + ).pipe( Effect.provide(ProviderLatestVersions.layer), Effect.provideService( HttpClient.HttpClient, @@ -174,7 +176,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { Effect.map((provider) => { expect(provider.versionAdvisory).toMatchObject({ status: "unknown", - currentVersion: "1.0.0", + currentVersion: "1.1.0", latestVersion: null, checkedAt: "2026-04-10T00:00:00.000Z", }); diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index b4ea15c5bd84..a27fab2084f7 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -899,17 +899,19 @@ export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( ) { const capabilities = maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver); + const currentVersion = + snapshot.installedVersion !== undefined ? snapshot.installedVersion : snapshot.version; const shouldResolveLatestVersion = options?.enableProviderUpdateChecks !== false && snapshot.enabled && snapshot.installed && - Boolean(snapshot.version); + Boolean(currentVersion); if (!shouldResolveLatestVersion) { return { ...snapshot, versionAdvisory: createProviderVersionAdvisory({ driver: snapshot.driver, - currentVersion: snapshot.version, + currentVersion, checkedAt: snapshot.checkedAt, maintenanceCapabilities: capabilities, }), @@ -921,7 +923,7 @@ export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( ...snapshot, versionAdvisory: createProviderVersionAdvisory({ driver: snapshot.driver, - currentVersion: snapshot.version, + currentVersion, latestVersion, checkedAt: DateTime.formatIso(yield* DateTime.now), maintenanceCapabilities: capabilities, diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 342b6c26db9c..0f082cff6360 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -166,6 +166,7 @@ const runtimeMock = { inventoryError: null as Error | null, connectionError: null as Error | null, inventoryCwd: null as string | null, + startCalls: 0, closeCalls: 0, sdkClientInputs: [] as Array<{ baseUrl: string; @@ -186,6 +187,7 @@ const runtimeMock = { this.state.inventoryError = null; this.state.connectionError = null; this.state.inventoryCwd = null; + this.state.startCalls = 0; this.state.closeCalls = 0; this.state.sdkClientInputs.length = 0; this.state.inventory = { @@ -199,6 +201,7 @@ const runtimeMock = { const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { startOpenCodeServerProcess: ({ serverPassword, environment }) => Effect.gen(function* () { + runtimeMock.state.startCalls += 1; yield* Effect.addFinalizer(() => Effect.sync(() => { runtimeMock.state.closeCalls += 1; @@ -551,6 +554,40 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("reports the upgraded CLI without restarting a borrowed OpenCode server", () => + Effect.scoped( + Effect.gen(function* () { + const settings = makeOpenCodeSettings(); + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: process.cwd(), + }); + yield* owner.withServer(() => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode 1.14.20\n"; + const snapshot = yield* checkOpenCodeProviderStatus( + settings, + process.cwd(), + probeOpenCodeRuntime(settings).pipe( + Effect.provideService( + HttpClient.HttpClient, + replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), + ), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), + ), + Effect.succeed([]), + ).pipe(Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, owner)); + + NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.equal(snapshot.installedVersion, "1.14.20"); + NodeAssert.equal(runtimeMock.state.startCalls, 1); + NodeAssert.equal(runtimeMock.state.closeCalls, 0); + }), + ); + }), + ), + ); + it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => Effect.gen(function* () { runtimeMock.state.versionStdout = "opencode v2.0.18\n"; @@ -572,6 +609,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { NodeAssert.equal(snapshot.status, "ready"); NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.equal("installedVersion" in snapshot, false); NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ "approval-required", "auto-accept-edits", @@ -652,11 +690,14 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode 1.14.20\n"; runtimeMock.state.inventoryError = new Error("opencode models failed"); const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.equal(snapshot.installedVersion, "1.14.20"); NodeAssert.equal(snapshot.models.length, 0); NodeAssert.equal( snapshot.message, @@ -669,6 +710,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { it.effect("does not send a local environment password to a configured server", () => Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode 1.14.20\n"; const snapshot = yield* checkProvider( makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), process.cwd(), @@ -676,6 +718,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i ); NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.equal("installedVersion" in snapshot, false); NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ { baseUrl: "http://127.0.0.1:9999", @@ -700,6 +743,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i NodeAssert.equal(snapshot.status, "warning"); NodeAssert.equal(snapshot.version, "2.0.18"); + NodeAssert.equal("installedVersion" in snapshot, false); NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ "approval-required", "auto-accept-edits", diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 1fe35dfadb1b..e4c985a13b33 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -674,7 +674,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu if (probed.generation === "v2") { return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); } - let version: string | null = probed.version; if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ presentation: OPENCODE_PRESENTATION, @@ -691,20 +690,23 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu }); } - const loadInventory = (server: { + const installedVersion = isExternalServer ? {} : { installedVersion: probed.version }; + let version = probed.version; + const loadInventory = Effect.fnUntraced(function* (server: { readonly url: string; readonly serverPassword?: string; readonly version: string; - }) => - openCodeRuntime - .loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }), - ) - .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + }) { + // Preserve the helper's version even when its inventory request fails. + version = server.version; + return yield* openCodeRuntime.loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + }), + ); + }); const inventoryEffect = isExternalServer ? openCodeRuntime .connectToOpenCodeServer({ @@ -729,41 +731,43 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu ), ); if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); + return { + ...fallback(Cause.squash(inventoryExit.cause), version, "inventory"), + ...installedVersion, + }; } - version = inventoryExit.value.version; - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value.inventory), + flattenOpenCodeModels(inventoryExit.value), customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); - const connectedCount = inventoryExit.value.inventory.providerList.connected.length; - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: true, - checkedAt, - models, - skills, - slashCommands: openCodeCommandsToServerProviderSlashCommands( - inventoryExit.value.inventory.commands, - ), - probe: { - installed: true, - version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.skills); + const connectedCount = inventoryExit.value.providerList.connected.length; + return { + ...buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + skills, + slashCommands: openCodeCommandsToServerProviderSlashCommands(inventoryExit.value.commands), + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", - }, - }); + }), + ...installedVersion, + }; }); From d6c67ceb1f9d682a1c8fa0d28f39b79119e90065 Mon Sep 17 00:00:00 2001 From: Akbar Danial Akma <44286537+akbarakma@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:57:40 +0700 Subject: [PATCH 025/458] fix(mobile): iOS composer text stays vertically aligned at every text size (#17855) Co-authored-by: Julius Marminge --- .../src/features/threads/ThreadComposer.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 041855e4bba2..705772f054fa 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -88,8 +88,9 @@ import { groupByProvider, isModelSelectionUnavailable, } from "../../lib/modelOptions"; -import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; +import { useScaledTextRole, type ScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { Menu } from "../../components/ui/Menu"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -127,6 +128,20 @@ import { export const COMPOSER_COLLAPSED_CHROME = 60; +const COLLAPSED_EDITOR_DEFAULT_INSET = 6; +// iOS puts line-height slack above glyphs. DM Sans's lowercase midline is +// descender (0.31em) + half x-height (0.504em) above the line bottom. +const LOWERCASE_MIDLINE_EM = 0.562; + +function lowercaseMidline(text: ScaledTextRole): number { + return text.lineHeight - LOWERCASE_MIDLINE_EM * text.fontSize; +} + +function collapsedEditorInset(text: ScaledTextRole): number { + const shift = lowercaseMidline(text) - lowercaseMidline(MOBILE_TYPOGRAPHY.body); + return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift)); +} + export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { @@ -914,7 +929,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps } scrollEnabled={isExpanded} singleLineCentered={!isExpanded} - contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6} + contentInsetVertical={ + isExpanded || Platform.OS === "android" ? 0 : collapsedEditorInset(bodyText) + } style={ isExpanded ? { From 29badb43acc8fd468672bf11cd029cb7aa084429 Mon Sep 17 00:00:00 2001 From: blue-az <32047402+blue-az@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:57:52 -0700 Subject: [PATCH 026/458] fix(server): Pi extension notifications render as notices, not tool calls (#15368) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../src/features/threads/thread-work-log.tsx | 2 +- .../provider-pi/src/server/adapter.test.ts | 18 +++++++++++++----- packages/provider-pi/src/server/adapter.ts | 10 +++------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 1b3c0b2efe2b..89657ce6b6d1 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1098,7 +1098,7 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( - {isSystemNotice ? row.summary : displayText} + {displayText} {answerPreview ? ( { message: "Ordering barrier", }); yield* takeEvent( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.toolName === "notify", + (event) => event.type === "turn_item.updated" && event.turnItem.type === "system_notice", ); assert.equal(runtime.providerSession.status, "running"); fake.queueState({ isStreaming: true }); @@ -2673,10 +2670,21 @@ describe("PiAdapterV2", () => { type: "extension_ui_request", id: "ui-cmd", method: "notify", - message: "done", + message: "Command finished\nwith details", notifyType: "info", }); yield* fake.emit({ type: "response", command: "prompt", success: true }); + const notice = yield* takeEvent( + (event) => + (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") || + event.type === "turn.terminal", + ); + assert.isTrue( + notice.type === "turn_item.updated" && + notice.turnItem.type === "system_notice" && + notice.turnItem.status === "completed" && + notice.turnItem.message === "Command finished\nwith details", + ); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); }).pipe(Effect.scoped, Effect.provide(layerTest)), diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 4a8f34c821dc..09436abffeee 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -1237,13 +1237,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), status: "completed", completedAt: emittedAt, - title: "notify", - type: "dynamic_tool", - toolName: "notify", - input: { - message, - notifyType: recordString(event, "notifyType") ?? "info", - }, + title: message, + type: "system_notice", + message, }, }); return; From c13d058b91ffc7c1198f5895aef4dbce3951f054 Mon Sep 17 00:00:00 2001 From: tiliakoos <73005126+tiliakoos@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:58:06 -0400 Subject: [PATCH 027/458] fix(web): the composer rests on scroll when a thread opens above its end (#15025) Co-authored-by: Julius Marminge --- .../chat/timelineScrollAnchoring.test.tsx | 26 +++++++++++++++++++ .../chat/timelineScrollAnchoring.ts | 9 +++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 6300a8033893..d868ef83f8ed 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -57,7 +57,33 @@ describe("timelineContentOverflowsViewport", () => { inset, ), ).toBe(false); + expect( + timelineContentOverflowsViewport( + buildState({ positions: [0, Number.NaN], sizes: [200] }), + inset, + ), + ).toBe(false); }); + + it.each([ + { measurement: "missing", sizes: [200] }, + { measurement: "NaN", sizes: [200, Number.NaN] }, + ])( + "reports overflow with a $measurement tail size without inventing anchor metrics", + ({ sizes }) => { + const state = buildState({ positions: [0, 900], sizes }); + expect(timelineContentOverflowsViewport(state, inset)).toBe(true); + expect(getRowBottom(state, 1)).toBeNull(); + expect( + getAnchoredTurnMetrics({ + state, + anchorIndex: 0, + composerOverlayHeight: 100, + anchorOffset: 24, + }), + ).toBeNull(); + }, + ); }); describe("timeline scroll anchoring", () => { diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 74b04e10b955..d06b56a52ebe 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -75,7 +75,7 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number) /** * The list's own content length includes the composer inset spacer, so this - * measures from the last row instead. Unknown row geometry or an unmeasured + * measures from the last row instead. Unknown row positions or an unmeasured * viewport counts as fitting. */ export function timelineContentOverflowsViewport( @@ -89,7 +89,12 @@ export function timelineContentOverflowsViewport( if (typeof scrollLength !== "number" || !Number.isFinite(scrollLength) || scrollLength <= 0) { return false; } - const lastBottom = getRowBottom(state, state.data.length - 1); + const lastIndex = state.data.length - 1; + // Overflow can use an unmeasured tail's lower bound; anchored-turn metrics need its height. + const lastTop = state.positionAtIndex(lastIndex); + const lastBottom = + getRowBottom(state, lastIndex) ?? + (typeof lastTop === "number" && Number.isFinite(lastTop) ? lastTop + 1 : null); if (lastBottom === null) { return false; } From 1225cdcadc342267b508780afed794767e7a3e40 Mon Sep 17 00:00:00 2001 From: Alice <81575558+aliceisjustplaying@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:58:19 +0000 Subject: [PATCH 028/458] fix(server): keep Pi notification identities distinct across threads (#15791) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5.1 --- .../provider-pi/src/server/adapter.test.ts | 42 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 4 +- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 410d8fb31be9..e2856925947e 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -1342,6 +1342,48 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("gives notify and extension-error items distinct ids in each thread", () => + Effect.gen(function* () { + const itemIds = new Set(); + for (const threadId of [THREAD_ID, ThreadId.make("thread-pi-test-2")]) { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake, "default", threadId); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Hello pi", + undefined, + 1, + threadId, + ); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-notify", + method: "notify", + message: "note", + }); + yield* fake.emit({ type: "extension_error", extensionPath: "/ext/a.ts", error: "boom" }); + for (const prefix of ["notify:", "extension-error:"]) { + const item = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId?.startsWith(prefix) === true, + ); + if (item.type === "turn_item.updated") itemIds.add(item.turnItem.id); + } + } + assert.equal(itemIds.size, 4); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("injects the T3 MCP extension and bearer when a session exists", () => Effect.gen(function* () { yield* (yield* McpProviderSessions.McpProviderSessions).set({ diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 09436abffeee..7e61700d9ea4 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -1228,7 +1228,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const message = recordString(event, "message") ?? ""; if (turn === null || message.length === 0) return; const emittedAt = yield* DateTime.now; - const nativeItemId = `notify:${turn.nextItemOrdinal}`; + const nativeItemId = `notify:${turn.providerTurn.id}:${turn.nextItemOrdinal}`; yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt); yield* emit({ type: "turn_item.updated", @@ -1389,7 +1389,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return; } const emittedAt = yield* DateTime.now; - const nativeItemId = `extension-error:${turn.nextItemOrdinal}`; + const nativeItemId = `extension-error:${turn.providerTurn.id}:${turn.nextItemOrdinal}`; const extensionName = piExtensionDisplayName(recordString(event, "extensionPath")); const extensionEvent = recordString(event, "event"); const detail = recordString(event, "error")?.trim(); From e6765094cb6b00a54dde18b4642bb92ed53837ad Mon Sep 17 00:00:00 2001 From: Nikhil Nigam <73949924+nikhilnigamnik@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:28:31 +0530 Subject: [PATCH 029/458] fix(web): show minimap previews as plain text, not raw markdown (#15715) Co-authored-by: Julius Marminge --- apps/web/package.json | 3 +- .../chat/timelineMinimapItems.test.ts | 44 ++++++++++++++++++ .../components/chat/timelineMinimapItems.ts | 45 ++++++++++++++++++- packages/ssh/src/tunnel.test.ts | 12 ++++- pnpm-lock.yaml | 9 ++-- 5 files changed, 107 insertions(+), 6 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index e6b1bc8471da..7eb1909a54c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -71,8 +71,10 @@ "react-markdown": "^10.1.0", "remark-gfm": "catalog:", "remark-math": "6.0.0", + "remark-parse": "catalog:", "tailwind-merge": "^3.4.0", "unbash": "^5.0.0", + "unified": "catalog:", "use-sync-external-store": "^1.6.0", "zustand": "^5.0.11" }, @@ -94,7 +96,6 @@ "oxc-transform-react": "~0.152.0", "playwright": "1.60.0", "tailwindcss": "^4.0.0", - "unified": "catalog:", "vite": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts index cad6c2a374b3..23b29f3af742 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.test.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -64,6 +64,50 @@ describe("timeline minimap previews", () => { expect(resolveTimelineMinimapPreview(null)).toBeNull(); }); + it("renders assistant markdown only when resolving a preview and keeps user text literal", () => { + const userText = "Explain **operators** and [x] in [the docs](https://example.com/docs)"; + const assistantText = [ + "## Operators", + "Use **either** option from [the docs](https://example.com/docs).", + "", + "| Operator | Meaning |", + "| --- | :---: |", + "| `a \\| b` | either |", + "", + "- [x] Match `**/*.ts` files", + "", + "[x] deployment complete", + "", + "> Note: ~~old~~ new ", + "> Next line", + "", + "```sh", + "# install deps", + "- **not bold**", + "const snake_case = a * b;", + "```", + "", + "foo
    bar", + "", + "
    Critical answer
    ", + ].join("\n"); + const [item] = deriveTimelineMinimapItems( + rows([ + ["user", userText], + ["assistant", assistantText], + ]), + ); + + expect(item).toMatchObject({ userText, assistantText }); + expect(resolveTimelineMinimapPreview(item!)).toEqual({ + ...item, + userText, + assistantText: + "Operators Use either option from the docs. Operator Meaning a | b either Match **/*.ts files [x] deployment complete Note: old new Next line # install deps - **not bold** const snake_case = a * b; foo bar Critical answer", + }); + expect(item).toMatchObject({ userText, assistantText }); + }); + it("shows fresh streaming text without changing the jump target", () => { const first = deriveTimelineMinimapItems( rows([ diff --git a/apps/web/src/components/chat/timelineMinimapItems.ts b/apps/web/src/components/chat/timelineMinimapItems.ts index 9603b9ac3a5d..faf6af941540 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.ts @@ -1,3 +1,8 @@ +import type { Nodes } from "mdast"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; export interface TimelineMinimapItem { @@ -47,6 +52,39 @@ function resolveFinalAssistantTextForTurn( return finalAssistantText; } +const markdownPreviewParser = unified().use(remarkParse).use(remarkGfm).freeze(); + +const INLINE_PARENT_TYPES = new Set([ + "paragraph", + "heading", + "tableCell", + "emphasis", + "strong", + "delete", + "link", + "linkReference", +]); + +function markdownNodeText(node: Nodes): string { + if (node.type === "html") { + return node.value + .replace(/|$)/g, "") + .replace(/<\/?([a-z][\w-]*)(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi, (_, tag: string) => + /^(?:address|article|aside|blockquote|br|dd|details|div|dl|dt|figcaption|figure|footer|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|summary|table|tbody|td|th|thead|tr|ul)$/i.test( + tag, + ) + ? " " + : "", + ); + } + if (node.type === "break") return " "; + if ("value" in node) return node.value; + if (node.type === "image" || node.type === "imageReference") return node.alt ?? ""; + if (!("children" in node)) return ""; + const separator = INLINE_PARENT_TYPES.has(node.type) ? "" : " "; + return node.children.map(markdownNodeText).join(separator); +} + function compactMinimapPreview(text: string | null | undefined) { const compact = text?.replace(/\s+/g, " ").trim() ?? ""; return compact.length > 0 ? compact : null; @@ -60,6 +98,11 @@ export function resolveTimelineMinimapPreview( : { ...item, userText: compactMinimapPreview(item.userText), - assistantText: compactMinimapPreview(item.assistantText), + // The tooltip shows three lines; bound parsing work while long responses stream. + assistantText: compactMinimapPreview( + item.assistantText === null + ? null + : markdownNodeText(markdownPreviewParser.parse(item.assistantText.slice(0, 1_500))), + ), }; } diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index fb80b1f37176..048104a42688 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1460,7 +1460,17 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { ), ); process.kill(zombie, "SIGKILL"); - assert.include((yield* runScript(home, ["-c", `ps -o stat= -p ${zombie}`])).stdout, "Z"); + // SIGKILL delivery is asynchronous; establish the zombie fixture before launching. + const zombieState = yield* runScript(home, [ + "-c", + `attempt=0; while [ "$attempt" -lt 100 ]; do + state=$(ps -o stat= -p ${zombie}) || exit 1 + case "$state" in *Z*) printf '%s\\n' "$state"; exit 0;; esac + attempt=$((attempt + 1)) + done; printf '%s\\n' "$state"; exit 1`, + ]); + assert.equal(zombieState.exitCode, 0, zombieState.stdout); + assert.include(zombieState.stdout, "Z"); yield* fs.writeFileString(`${stateDir}/pid`, `${zombie}\n`); yield* fs.writeFileString( `${home}/.t3/userdata/server-runtime.json`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62bd52bbbb2d..c689f1e4ab62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -981,12 +981,18 @@ importers: remark-math: specifier: 6.0.0 version: 6.0.0 + remark-parse: + specifier: 'catalog:' + version: 11.0.0 tailwind-merge: specifier: ^3.4.0 version: 3.6.0 unbash: specifier: ^5.0.0 version: 5.0.0 + unified: + specifier: 'catalog:' + version: 11.0.5 use-sync-external-store: specifier: ^1.6.0 version: 1.6.0(react@19.3.0) @@ -1045,9 +1051,6 @@ importers: tailwindcss: specifier: 4.3.3 version: 4.3.3 - unified: - specifier: 'catalog:' - version: 11.0.5 vite: specifier: npm:@voidzero-dev/vite-plus-core@1.0.0 version: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' From b4cebe8b1ddbcffec4c9b0ba9078202b31b75a2a Mon Sep 17 00:00:00 2001 From: Yoru <0tox@iterelle.tech> Date: Mon, 19 Oct 2026 18:58:43 -0700 Subject: [PATCH 030/458] fix(desktop): skip auxiliary macOS snapshot windows (#12978) Co-authored-by: Julius Marminge --- .../desktop/src/snapShot/ActiveWindow.test.ts | 141 ++++++++++++++++-- apps/desktop/src/snapShot/ActiveWindow.ts | 87 ++++++++--- 2 files changed, 191 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/snapShot/ActiveWindow.test.ts b/apps/desktop/src/snapShot/ActiveWindow.test.ts index ddc760cbb9d0..c1849b3eed6e 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.test.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.test.ts @@ -28,19 +28,34 @@ function stubMacLookup(stdout: string) { ); } +function macLookup( + windows: ReadonlyArray<{ + id: number; + title: string; + bounds: { x: number; y: number; width: number; height: number }; + alpha?: number; + }>, +) { + return JSON.stringify({ + owner: { + name: "Editor", + processId: 123, + path: "/Applications/Editor.app", + bundleId: "com.example.editor", + }, + windows: windows.map((window) => ({ ...window, alpha: window.alpha ?? 1 })), + }); +} + it("parses the frontmost macOS window from the osascript lookup", async () => { stubMacLookup( - JSON.stringify({ - id: 42, - title: "main.ts", - bounds: { x: 10, y: 20, width: 800, height: 600 }, - owner: { - name: "Editor", - processId: 123, - path: "/Applications/Editor.app", - bundleId: "com.example.editor", + macLookup([ + { + id: 42, + title: "main.ts", + bounds: { x: 10, y: 20, width: 800, height: 600 }, }, - }) + "\n", + ]) + "\n", ); const window = await activeWindow("darwin"); @@ -66,10 +81,15 @@ it("parses the frontmost macOS window from the osascript lookup", async () => { it("omits an empty macOS bundle identifier", async () => { stubMacLookup( JSON.stringify({ - id: 7, - title: "", - bounds: { x: 0, y: 0, width: 1, height: 1 }, owner: { name: "cli", processId: 9, path: "", bundleId: "" }, + windows: [ + { + id: 7, + title: "", + bounds: { x: 0, y: 0, width: 1, height: 1 }, + alpha: 1, + }, + ], }), ); @@ -78,12 +98,107 @@ it("omits an empty macOS bundle identifier", async () => { assert.deepEqual(window?.owner, { name: "cli", processId: 9, path: "" }); }); +it.each([ + { + name: "transparent and untitled helpers", + helpers: [ + { + id: 1, + title: "", + bounds: { x: -1, y: -44, width: 1_680, height: 44 }, + alpha: 0, + }, + { id: 2, title: "", bounds: { x: 0, y: 0, width: 1_680, height: 68 } }, + ], + content: { + id: 3, + title: "Document", + bounds: { x: 0, y: 0, width: 1_680, height: 1_050 }, + }, + }, + { + name: "Chrome strips stacked above the content window", + helpers: [ + { id: 75, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 41 } }, + { id: 74, title: "", bounds: { x: 0, y: 80, width: 1_800, height: 81 } }, + { id: 1691, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 158 } }, + ], + content: { + id: 71, + title: "(10) Video - YouTube", + bounds: { x: 0, y: 161, width: 1_800, height: 1_008 }, + }, + }, + { + name: "a smaller front window's strips with a larger background window", + helpers: [ + { id: 75, title: "", bounds: { x: 100, y: 139, width: 1_200, height: 41 } }, + { id: 74, title: "", bounds: { x: 100, y: 180, width: 1_200, height: 81 } }, + ], + content: { + id: 71, + title: "Video - YouTube", + bounds: { x: 100, y: 261, width: 1_200, height: 700 }, + }, + background: [ + { id: 80, title: "Other document", bounds: { x: 0, y: 39, width: 1_800, height: 1_130 } }, + ], + }, +])("selects the content window behind $name", async ({ helpers, content, background = [] }) => { + stubMacLookup(macLookup([...helpers, content, ...background])); + + const window = await activeWindow("darwin"); + + assert.strictEqual(window?.id, content.id); + assert.deepEqual(window?.bounds, content.bounds); +}); + +it.each([ + { name: "untitled dialog", title: "", bounds: { x: 300, y: 200, width: 400, height: 240 } }, + { name: "titled palette", title: "Palette", bounds: { x: 0, y: 0, width: 800, height: 50 } }, + { name: "offset untitled strip", title: "", bounds: { x: 100, y: 100, width: 800, height: 50 } }, + { name: "tall untitled window", title: "", bounds: { x: 0, y: 0, width: 800, height: 900 } }, +])("keeps a frontmost $name ahead of a larger document", async ({ title, bounds }) => { + stubMacLookup( + macLookup([ + { id: 1, title, bounds }, + { id: 2, title: "Document", bounds: { x: 0, y: 0, width: 800, height: 1_000 } }, + ]), + ); + + const window = await activeWindow("darwin"); + + assert.strictEqual(window?.id, 1); + assert.deepEqual(window?.bounds, bounds); +}); + +it("keeps an untitled content window ahead of a much taller background window", async () => { + const bounds = { x: 0, y: 0, width: 800, height: 900 }; + stubMacLookup( + macLookup([ + { id: 1, title: "", bounds }, + { id: 2, title: "Background document", bounds: { x: 0, y: 0, width: 800, height: 4_000 } }, + ]), + ); + + const window = await activeWindow("darwin"); + + assert.strictEqual(window?.id, 1); + assert.deepEqual(window?.bounds, bounds); +}); + it("resolves undefined when macOS has no frontmost window", async () => { stubMacLookup("\n"); assert.isUndefined(await activeWindow("darwin")); }); +it("resolves undefined when macOS reports no visible windows", async () => { + stubMacLookup(macLookup([])); + + assert.isUndefined(await activeWindow("darwin")); +}); + it("composes the Windows foreground window from Win32 calls", async () => { const api = { getForegroundWindow: vi.fn(() => 0x1_f123_4567n), diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index f62669a39bf3..08c6cfa6ba04 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -30,23 +30,32 @@ export type ActiveWindow = { const MAC_LOOKUP_TIMEOUT_MS = 5_000; -const MacActiveWindow = Schema.Struct({ - id: Schema.Number, - title: Schema.String, - bounds: Schema.Struct({ - x: Schema.Number, - y: Schema.Number, - width: Schema.Number, - height: Schema.Number, - }), +const MAC_HELPER_STRIP_MAX_THICKNESS_RATIO = 0.25; + +const MacActiveWindowLookup = Schema.Struct({ owner: Schema.Struct({ name: Schema.String, processId: Schema.Number, path: Schema.String, bundleId: Schema.String, }), + windows: Schema.Array( + Schema.Struct({ + id: Schema.Number, + title: Schema.String, + bounds: Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + alpha: Schema.Number, + }), + ), }); -const decodeMacActiveWindow = Schema.decodeUnknownSync(Schema.fromJsonString(MacActiveWindow)); +const decodeMacActiveWindowLookup = Schema.decodeUnknownSync( + Schema.fromJsonString(MacActiveWindowLookup), +); const MAC_LOOKUP_SCRIPT = ` ObjC.import("CoreGraphics"); @@ -55,30 +64,38 @@ function run() { const app = $.NSWorkspace.sharedWorkspace.frontmostApplication; if (app.isNil()) return ""; const pid = app.processIdentifier; + let ownerName = String(app.localizedName.js || ""); const list = $.CGWindowListCopyWindowInfo( $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, $.kCGNullWindowID, ); $.CFMakeCollectable(list); const count = $.CFArrayGetCount(list); + const windows = []; for (let i = 0; i < count; i++) { const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)); if (w.objectForKey("kCGWindowOwnerPID").js !== pid) continue; if (w.objectForKey("kCGWindowLayer").js !== 0) continue; + if (!ownerName) { + ownerName = String(ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""); + } const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds")); - return JSON.stringify({ + windows.push({ id: w.objectForKey("kCGWindowNumber").js, title: String(ObjC.unwrap(w.objectForKey("kCGWindowName")) || ""), bounds: { x: b.X, y: b.Y, width: b.Width, height: b.Height }, - owner: { - name: String(app.localizedName.js || ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""), - processId: pid, - path: String(app.bundleURL.path.js || ""), - bundleId: String(app.bundleIdentifier.js || ""), - }, + alpha: w.objectForKey("kCGWindowAlpha").js, }); } - return ""; + return JSON.stringify({ + owner: { + name: ownerName, + processId: pid, + path: String(app.bundleURL.path.js || ""), + bundleId: String(app.bundleIdentifier.js || ""), + }, + windows, + }); }`; function runMacLookup(): Promise { @@ -86,7 +103,7 @@ function runMacLookup(): Promise { NodeChildProcess.execFile( "/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_LOOKUP_SCRIPT], - { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 64 * 1024 }, + { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout) => { if (error) reject(error); else resolve(stdout); @@ -98,17 +115,39 @@ function runMacLookup(): Promise { async function macActiveWindow(): Promise { const output = (await runMacLookup()).trim(); if (!output) return undefined; - const window = decodeMacActiveWindow(output); + const lookup = decodeMacActiveWindowLookup(output); + const visible = lookup.windows.filter( + (window) => window.alpha > 0 && window.bounds.width > 0 && window.bounds.height > 0, + ); + // Helpers belong to the content behind them, which may not be the app's largest window. + const window = visible.find((candidate, index) => { + if (candidate.title.trim() !== "") return true; + const bounds = candidate.bounds; + return !visible.some((reference, referenceIndex) => { + if (referenceIndex <= index) return false; + return ( + (bounds.x === reference.bounds.x && + bounds.width === reference.bounds.width && + bounds.height <= bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && + bounds.height <= reference.bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) || + (bounds.y === reference.bounds.y && + bounds.height === reference.bounds.height && + bounds.width <= bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && + bounds.width <= reference.bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) + ); + }); + }); + if (!window) return undefined; return { platform: "macos", id: window.id, title: window.title, bounds: window.bounds, owner: { - name: window.owner.name, - processId: window.owner.processId, - path: window.owner.path, - ...(window.owner.bundleId ? { bundleId: window.owner.bundleId } : {}), + name: lookup.owner.name, + processId: lookup.owner.processId, + path: lookup.owner.path, + ...(lookup.owner.bundleId ? { bundleId: lookup.owner.bundleId } : {}), }, }; } From 806b53994568e4d575830149d1b964afad169e5c Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 20 Oct 2026 01:58:55 +0000 Subject: [PATCH 031/458] fix(ssh): deduplicate configured host suggestions (#15630) Co-authored-by: Julius Marminge --- .../src/ssh/DesktopSshEnvironment.test.ts | 6 +- .../settings/ConnectionsSettings.tsx | 2 +- apps/web/src/state/desktopSshHosts.test.ts | 3 +- apps/web/src/state/desktopSshHosts.ts | 5 +- packages/ssh/src/config.test.ts | 113 ++++++++++++-- packages/ssh/src/config.ts | 140 +++++++++++++----- 6 files changed, 213 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 1fe2b86aae7b..adb0a12e8f06 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -58,7 +58,7 @@ describe("sshEnvironment", () => { yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); yield* fs.writeFileString( path.join(sshDir, "config"), - ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), + ["Include config.d/*.conf", "Host devbox", " HostName devbox.example.com", ""].join("\n"), ); yield* fs.writeFileString( path.join(sshDir, "config.d", "team.conf"), @@ -92,7 +92,7 @@ describe("sshEnvironment", () => { }, { alias: "devbox", - hostname: "devbox", + hostname: "devbox.example.com", username: null, port: null, source: "ssh-config", @@ -106,7 +106,7 @@ describe("sshEnvironment", () => { }, { alias: "staging", - hostname: "staging", + hostname: "staging.example.com", username: null, port: null, source: "ssh-config", diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index fe02e5be7b4a..b984de57f764 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2198,7 +2198,7 @@ export function ConnectionsSettings() { const address = formatDesktopSshTarget(target); return ( !savedDesktopSshEnvironmentKeys.has(target.alias) && - !savedDesktopSshEnvironmentKeys.has(address) + (target.source === "ssh-config" || !savedDesktopSshEnvironmentKeys.has(address)) ); }); const filteredDiscoveredSshHosts = filterDiscoveredSshHosts( diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts index 4b6b60250e47..631dd9b89e70 100644 --- a/apps/web/src/state/desktopSshHosts.test.ts +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -87,8 +87,9 @@ describe("filterDiscoveredSshHosts", () => { ]); }); - it("matches case-insensitively", () => { + it("matches aliases and hostnames case-insensitively", () => { expect(filterDiscoveredSshHosts(suggestions, "PINOT")).toEqual([suggestions[1]]); + expect(filterDiscoveredSshHosts(hosts, "box.LOCAL")).toEqual(hosts); }); it("returns an empty array when no hosts match", () => { diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts index 2ff057485898..05d19790283d 100644 --- a/apps/web/src/state/desktopSshHosts.ts +++ b/apps/web/src/state/desktopSshHosts.ts @@ -18,7 +18,10 @@ export function filterDiscoveredSshHosts( const alias = host.alias.toLowerCase(); if (alias.startsWith(normalizedQuery)) { prefixMatches.push(host); - } else if (alias.includes(normalizedQuery)) { + } else if ( + alias.includes(normalizedQuery) || + host.hostname.toLowerCase().includes(normalizedQuery) + ) { substringMatches.push(host); } } diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 49e91b2adbc9..2c0b79979665 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -19,7 +19,7 @@ function makeTempHomeDir() { } describe("ssh config", () => { - it.effect("follows quoted and escaped Include paths like OpenSSH", () => + it.effect("deduplicates hosts through quoted and escaped Include paths like OpenSSH", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -28,8 +28,14 @@ describe("ssh config", () => { const quotedDir = path.join(homeDir, "quoted dir"); yield* fs.makeDirectory(sshDir); yield* fs.makeDirectory(quotedDir); - yield* fs.writeFileString(path.join(quotedDir, "team#1=a.conf"), "Host quoted\n"); - yield* fs.writeFileString(path.join(homeDir, "escaped space.conf"), "Host escaped\n"); + yield* fs.writeFileString( + path.join(quotedDir, "team#1=a.conf"), + 'Host quoted\n HostName quoted".example.com"\nHost *\n', + ); + yield* fs.writeFileString( + path.join(homeDir, "escaped space.conf"), + "Host escaped\n HostName escaped.example.com\nHost *\n", + ); yield* fs.writeFileString( path.join(sshDir, "config"), [ @@ -39,10 +45,18 @@ describe("ssh config", () => { ].join("\n"), ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + "escaped.example.com ssh-ed25519 AAAA\nquoted.example.com ssh-ed25519 AAAA\n", + ); + const hosts = yield* discoverSshHosts({ homeDir }); assert.deepEqual( - hosts.map((host) => host.alias), - ["escaped", "quoted"], + hosts.map((host) => [host.alias, host.hostname, host.source]), + [ + ["escaped", "escaped.example.com", "ssh-config"], + ["quoted", "quoted.example.com", "ssh-config"], + ], ); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); @@ -57,9 +71,10 @@ describe("ssh config", () => { yield* fs.writeFileString( path.join(sshDir, "config"), [ - "Host devbox", - " HostName devbox.example.com", + "Host devbox devbox-admin", + " HostName 'devbox.example.com'", "Host=equalsbox", + "Host *", "Include=config.d/*.conf", "", ].join("\n"), @@ -77,6 +92,8 @@ describe("ssh config", () => { yield* fs.writeFileString( path.join(sshDir, "known_hosts"), [ + "devbox.example.com ssh-ed25519 AAAA", + "staging.example.com ssh-ed25519 AAAA", "known.example.com ssh-ed25519 AAAA", "|1|hashed|entry ssh-ed25519 AAAA", "[bastion.example.com]:2222 ssh-ed25519 AAAA", @@ -95,7 +112,14 @@ describe("ssh config", () => { }, { alias: "devbox", - hostname: "devbox", + hostname: "devbox.example.com", + username: null, + port: null, + source: "ssh-config", + }, + { + alias: "devbox-admin", + hostname: "devbox.example.com", username: null, port: null, source: "ssh-config", @@ -116,7 +140,7 @@ describe("ssh config", () => { }, { alias: "staging", - hostname: "staging", + hostname: "staging.example.com", username: null, port: null, source: "ssh-config", @@ -125,6 +149,77 @@ describe("ssh config", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); + it.effect("retains raw targets when included hostnames are inactive or uncertain", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* makeTempHomeDir(); + const sshDir = path.join(homeDir, ".ssh"); + yield* fs.makeDirectory(sshDir, { recursive: true }); + yield* fs.writeFileString( + path.join(sshDir, "config"), + [ + "Host WORK*", + " HostName case-sensitive.example.com", + "Host work", + "Host dev", + " Include conditional.conf", + "Host prod", + "Host matched", + "Match originalhost matched", + " Include match.conf", + "Host matched", + " HostName fallback.example.com", + "Host bracketed", + " Include bracket-[ab].conf", + " HostName bracket-fallback.example.com", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(sshDir, "conditional.conf"), + "Host prod\n HostName blocked.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "match.conf"), + "Host matched\n HostName match.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "bracket-a.conf"), + "HostName bracket-real.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + [ + "bracket-real.example.com ssh-ed25519 AAAA", + "bracket-fallback.example.com ssh-ed25519 AAAA", + "blocked.example.com ssh-ed25519 AAAA", + "case-sensitive.example.com ssh-ed25519 AAAA", + "match.example.com ssh-ed25519 AAAA", + "fallback.example.com ssh-ed25519 AAAA", + "", + ].join("\n"), + ); + + const hosts = yield* discoverSshHosts({ homeDir }); + assert.deepEqual( + hosts.filter((host) => host.source === "known-hosts").map((host) => host.hostname), + [ + "blocked.example.com", + "bracket-fallback.example.com", + "bracket-real.example.com", + "case-sensitive.example.com", + "fallback.example.com", + "match.example.com", + ], + ); + assert.equal(hosts.find((host) => host.alias === "bracketed")?.hostname, "bracketed"); + assert.equal(hosts.find((host) => host.alias === "prod")?.hostname, "prod"); + assert.equal(hosts.find((host) => host.alias === "work")?.hostname, "work"); + assert.equal(hosts.find((host) => host.alias === "matched")?.hostname, "matched"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("parses known_hosts entries without returning hashed hosts", () => Effect.sync(() => { assert.deepEqual( diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index f2c52e8857d9..538f8b1c72b7 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -14,14 +14,15 @@ const NO_HOSTS: ReadonlyArray = [] as const; // Mirrors OpenSSH readconf: the keyword may be followed by one optional "=", // then the arguments are split like misc.c argv_split() with quotes, the // escapes \\ \" \' and unquoted "\ ", and "#" starting a comment only at -// the start of a token. A line with an unterminated quote is ignored. -function splitDirectiveArgs(line: string): ReadonlyArray { +// the start of a token. Invalid quoting leaves hostname resolution uncertain. +function parseSshConfigLine(line: string) { const match = /^\s*([^\s=]+)\s*=?\s*(.*)$/u.exec(line); if (!match) { - return []; + return null; } - const args = [match[1]!]; + const directive = match[1]!.toLowerCase(); + const args: Array = []; const rest = match[2]!; let token: string | null = null; let quote: string | null = null; @@ -55,12 +56,12 @@ function splitDirectiveArgs(line: string): ReadonlyArray { } } if (quote !== null) { - return []; + return { directive, args: null }; } if (token !== null) { args.push(token); } - return args; + return { directive, args }; } function expandHomePath(input: string, homeDir: string): string { @@ -119,68 +120,117 @@ const expandGlob = Effect.fnUntraced(function* (pattern: string) { matchedPaths.push(entryPath); } } - return matchedPaths.toSorted((left, right) => left.localeCompare(right)); + return matchedPaths.toSorted(); }); +type HostCondition = ReadonlyArray | null; + +interface HostNameRule { + readonly conditions: ReadonlyArray; + readonly hostname: string | null; +} + +function matchesHostPatterns(alias: string, patterns: ReadonlyArray): boolean { + let matched = false; + for (const pattern of patterns) { + const negated = pattern.startsWith("!"); + const hostPattern = negated ? pattern.slice(1) : pattern; + const matches = + hostPattern === "*" || + (hasSshPattern(hostPattern) ? globToRegExp(hostPattern).test(alias) : hostPattern === alias); + if (!matches) continue; + if (negated) return false; + matched = true; + } + return matched; +} + +function resolveConfiguredHostname(alias: string, rules: ReadonlyArray) { + for (const { conditions, hostname } of rules) { + if ( + conditions.some((condition) => condition !== null && !matchesHostPatterns(alias, condition)) + ) { + continue; + } + // SSH takes the first value; an uncertain earlier value blocks later fallbacks. + if (conditions.includes(null) || hostname === null) return null; + if (/%(?!h)/u.test(hostname) || hostname.includes("$")) return null; + return hostname.replaceAll("%h", alias); + } + return null; +} + const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( filePath: string, - visited = new Set(), + visited: Set, homeDir: string, -): Effect.fn.Return< - ReadonlyArray, - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path -> { + aliases: Set, + hostNameRules: Array, + inheritedConditions: ReadonlyArray, +): Effect.fn.Return { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const resolvedPath = path.resolve(filePath); - if (visited.has(resolvedPath) || !(yield* fs.exists(resolvedPath))) { - return NO_HOSTS; + if (visited.has(resolvedPath)) { + // Read each file once, but never reuse its rules under a different Include context. + hostNameRules.push({ conditions: inheritedConditions, hostname: null }); + return; } + if (!(yield* fs.exists(resolvedPath))) return; visited.add(resolvedPath); - const aliases = new Set(); const directory = path.dirname(resolvedPath); const raw = yield* fs.readFileString(resolvedPath); + let condition: HostCondition = ["*"]; for (const line of raw.split(/\r?\n/u)) { - const [directive = "", ...rawArgs] = splitDirectiveArgs(line); - const normalizedDirective = directive.toLowerCase(); - if (normalizedDirective === "include") { - for (const includePattern of rawArgs) { + const parsed = parseSshConfigLine(line); + if (!parsed) continue; + const { directive, args } = parsed; + const conditions = [...inheritedConditions, condition]; + + if (directive === "host") { + condition = args; + for (const alias of args ?? []) { + if (alias.length > 0 && !hasSshPattern(alias)) aliases.add(alias); + } + } else if (directive === "match") { + condition = null; + } else if (directive === "hostname") { + hostNameRules.push({ conditions, hostname: args?.length === 1 ? args[0]! : null }); + } else if (directive === "include") { + if (args === null) { + hostNameRules.push({ conditions, hostname: null }); + continue; + } + for (const includePattern of args) { const resolvedPattern = yield* resolveSshConfigIncludePattern( includePattern, directory, homeDir, ); + if ( + /%|\$\{|\[/u.test(includePattern) || + (includePattern.startsWith("~") && !/^~(?=$|\/|\\)/u.test(includePattern)) || + hasSshPattern(path.dirname(resolvedPattern)) + ) { + hostNameRules.push({ conditions, hostname: null }); + continue; + } const includedPaths = yield* expandGlob(resolvedPattern); for (const includedPath of includedPaths) { - const includedAliases = yield* collectSshConfigAliasesFromFile( + yield* collectSshConfigAliasesFromFile( includedPath, visited, homeDir, + aliases, + hostNameRules, + conditions, ); - for (const alias of includedAliases) { - aliases.add(alias); - } } } - continue; - } - - if (normalizedDirective !== "host") { - continue; - } - - for (const alias of rawArgs) { - if (alias.length === 0 || hasSshPattern(alias)) { - continue; - } - aliases.add(alias); } } - - return [...aliases].toSorted((left, right) => left.localeCompare(right)); }); function normalizeKnownHostsHostname(rawHost: string): string { @@ -252,18 +302,26 @@ export const discoverSshHosts = Effect.fnUntraced( } const sshDirectory = path.join(homeDir, ".ssh"); - const configAliases = yield* collectSshConfigAliasesFromFile( + const configAliases = new Set(); + const hostNameRules: Array = []; + yield* collectSshConfigAliasesFromFile( path.join(sshDirectory, "config"), new Set(), homeDir, + configAliases, + hostNameRules, + [], ); const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts")); const discovered = new Map(); + const configuredHostnames = new Set(); for (const alias of configAliases) { + const hostname = resolveConfiguredHostname(alias, hostNameRules); + if (hostname !== null) configuredHostnames.add(hostname.toLowerCase()); discovered.set(alias, { alias, - hostname: alias, + hostname: hostname ?? alias, username: null, port: null, source: "ssh-config", @@ -271,7 +329,7 @@ export const discoverSshHosts = Effect.fnUntraced( } for (const hostname of knownHosts) { - if (discovered.has(hostname)) { + if (discovered.has(hostname) || configuredHostnames.has(hostname.toLowerCase())) { continue; } discovered.set(hostname, { From c1a24f884edd7f64f2039c89df9d5918e970d20b Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:05:20 +0200 Subject: [PATCH 032/458] fix(forgejo): a pull request watch passes once the required checks pass (#18775) Co-authored-by: Julius Marminge --- .../src/server/ForgejoPullRequestProvider.ts | 63 +++-- .../ForgejoSourceControlProvider.test.ts | 230 +++++++++++++++--- .../src/server/forgejoRequiredChecks.ts | 52 ++++ 3 files changed, 294 insertions(+), 51 deletions(-) create mode 100644 packages/source-control-forgejo/src/server/forgejoRequiredChecks.ts diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 565b12936a29..8d6b46daf93b 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -33,8 +33,11 @@ import { forgejoChecks, forgejoReactions, } from "./forgejoPullRequestJson.ts"; +import { ForgejoBranchStatusChecks, markRequiredForgejoChecks } from "./forgejoRequiredChecks.ts"; import { definition } from "../client/definition.ts"; +const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); + const CAPABILITIES: PullRequestCapabilities = { diff: true, viewedFiles: "environment", @@ -202,6 +205,44 @@ export const make = Effect.gen(function* () { ); return permissions(repo, pr, viewer); }); + const readChecks = Effect.fn("ForgejoPullRequestProvider.readChecks")(function* ( + input: ProviderRepositoryRef, + pr: typeof ForgejoPullRequest.Type, + ) { + const branch = read( + { + ...input, + path: `${repoPath(input)}/branches/${pr.base.ref.split("/").map(encodeURIComponent).join("/")}`, + }, + ForgejoBranchStatusChecks, + ).pipe( + Effect.catchTags({ + PullRequestProviderError: (error) => { + if ( + isForgejoCliError(error.cause) && + (error.cause.reason === "forbidden" || error.cause.reason === "not-found") + ) { + return Effect.succeed({}); + } + return Effect.fail(error); + }, + }), + ); + const [statuses, policy] = yield* Effect.all( + [ + page( + { + ...input, + path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, + }, + ForgejoStatus, + ), + branch, + ], + { concurrency: 2 }, + ); + return markRequiredForgejoChecks(forgejoChecks(statuses.items, pr.html_url), policy); + }); const unsupported = (operation: string) => Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`)); const provider: PullRequestProviderApi = { @@ -244,17 +285,7 @@ export const make = Effect.gen(function* () { getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")( function* (input) { const pr = yield* getPull(input); - const statuses = yield* page( - { - ...input, - path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, - }, - ForgejoStatus, - ); - return { - state: forgejoChangeRequest(pr).state, - checks: forgejoChecks(statuses.items, pr.html_url), - }; + return { state: forgejoChangeRequest(pr).state, checks: yield* readChecks(input, pr) }; }, ), getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) { @@ -262,13 +293,7 @@ export const make = Effect.gen(function* () { [getPull(input), getRepo(input), getViewer(input)], { concurrency: 3 }, ); - const statuses = yield* page( - { - ...input, - path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, - }, - ForgejoStatus, - ); + const checks = yield* readChecks(input, pr); return { ...forgejoChangeRequest(pr), headSha: pr.head.sha, @@ -278,7 +303,7 @@ export const make = Effect.gen(function* () { const actor = forgejoActor(user); return actor ? [actor] : []; }), - checks: forgejoChecks(statuses.items, pr.html_url), + checks, baseComparison: !pr.merge_base ? "unknown" : pr.merge_base === pr.base.sha diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index a6a453c3f327..8846a6b6af39 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -136,7 +136,8 @@ it.effect("reads the newest Forgejo checks without repository or viewer requests }); assert.strictEqual(result.state, "open"); assert.strictEqual(result.checks[0]?.status, "failure"); - assert.deepStrictEqual(paths, [ + assert.deepStrictEqual(paths.toSorted(), [ + "repos/acme/web/branches/main", "repos/acme/web/pulls/1", "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1", "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2", @@ -146,16 +147,18 @@ it.effect("reads the newest Forgejo checks without repository or viewer requests Layer.mock(ForgejoCli.ForgejoCli)({ api: (input) => { paths.push(input.path); - assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/); + assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head|branches\/main)/); return Effect.succeed( processOutput( - input.path.endsWith("pulls/1") - ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false, + input.path.endsWith("branches/main") + ? `{"name":"main","protected":false,"enable_status_check":false,"status_check_contexts":[]}` + : input.path.endsWith("pulls/1") + ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false, "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null}, "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}` - : input.path.endsWith("page=1") - ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00.500Z"},{"context":"build","status":"success","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]` - : "[]", + : input.path.endsWith("page=1") + ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00.500Z"},{"context":"build","status":"success","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]` + : "[]", ), ); }, @@ -164,6 +167,164 @@ it.effect("reads the newest Forgejo checks without repository or viewer requests ); }); +const requiredChecksPullRequest = { + number: 7, + title: "Required checks", + body: "", + html_url: "https://forgejo.test/acme/web/pulls/7", + user: { login: "maria" }, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "release/1.x", sha: "base", repo: null }, + created_at: "2026-10-12T00:00:00Z", + updated_at: "2026-10-12T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], +}; +const requiredChecksStatuses = [ + ["ci/required-build", "success"], + ["ci/optional-lint", "failure"], +].map(([context, status]) => ({ + context, + status, + description: null, + target_url: null, + updated_at: "2026-10-12T00:00:00Z", +})); + +const requiredChecksInput = { + cwd: "/repo", + repository: "acme/web", + host: "forgejo.test", + number: 7, +}; +const requiredChecksLayer = ( + branch: Effect.Effect, + statuses = requiredChecksStatuses, +) => + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + const [path, query] = input.path.split("?"); + const respond = (response: unknown) => + encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput)); + switch (path) { + case "repos/acme/web/pulls/7": + return respond(requiredChecksPullRequest); + case "repos/acme/web/statuses/head": + return respond(new URLSearchParams(query).get("page") === "1" ? statuses : []); + case "repos/acme/web/branches/release/1.x": + return Effect.flatMap(branch, respond); + case "repos/acme/web": + return respond({ full_name: "acme/web", permissions: { push: true, admin: false } }); + case "user": + return respond({ login: "maria" }); + default: + return Effect.die(`Unexpected Forgejo request: ${input.path}`); + } + }, + }); + +it.effect.each([ + ["ci/required-deploy", true], + ["test-*", true], + ["ci/[ab]", false], +] as const)("keeps missing required pattern %s pending (supported: %s)", ([missing, supported]) => + Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const readChecks = provider.getChangeRequestChecks; + if (readChecks === undefined) return yield* Effect.die("checks read missing"); + const summary = yield* readChecks(requiredChecksInput); + const detail = yield* provider.getChangeRequest(requiredChecksInput); + for (const checks of [summary.checks, detail.checks]) { + assert.deepStrictEqual( + checks.map((check) => [check.name, check.status, check.required]), + [ + ["ci/required-build", "success", supported ? true : undefined], + ["ci/optional-lint", supported ? "failure" : "success", undefined], + [missing, "pending", supported ? true : undefined], + ], + ); + if (!supported) { + assert.strictEqual( + checks.at(-1)?.description, + "Required check pattern cannot be evaluated.", + ); + } + } + }).pipe( + Effect.provide( + requiredChecksLayer( + Effect.succeed({ + name: "release/1.x", + protected: true, + enable_status_check: true, + status_check_contexts: ["ci/required-*", missing, missing], + }), + supported + ? requiredChecksStatuses + : requiredChecksStatuses.map((status) => ({ ...status, status: "success" })), + ), + ), + ), +); + +it.effect.each([ + ["older branch metadata", Effect.succeed({ name: "release/1.x", protected: true })], + ...(["not-found", "forbidden"] as const).map( + (reason) => + [ + reason, + Effect.fail( + new ForgejoCli.ForgejoCliError({ + command: "fj", + cwd: "/repo", + reason, + httpStatus: reason === "not-found" ? 404 : 403, + detail: "Branch metadata unavailable.", + }), + ), + ] as const, + ), +] as const)("keeps all statuses gating with %s", ([, branch]) => + Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const readChecks = provider.getChangeRequestChecks; + if (readChecks === undefined) return yield* Effect.die("checks read missing"); + const summary = yield* readChecks(requiredChecksInput); + const detail = yield* provider.getChangeRequest(requiredChecksInput); + for (const checks of [summary.checks, detail.checks]) { + assert.deepStrictEqual( + checks.map((check) => [check.name, check.status, check.required]), + [ + ["ci/required-build", "success", undefined], + ["ci/optional-lint", "failure", undefined], + ], + ); + } + }).pipe(Effect.provide(requiredChecksLayer(branch))), +); + +it.effect.each([ + ["rate limit", { reason: "rate-limit", httpStatus: 429 }, "rate-limited"], + ["authentication failure", { reason: "authentication", httpStatus: 401 }, "unauthenticated"], + ["server failure", { httpStatus: 503 }, "failed"], + ["network failure", {}, "failed"], +] as const)("propagates branch metadata %s", ([detail, fields, reason]) => { + const cause = new ForgejoCli.ForgejoCliError({ command: "fj", cwd: "/repo", detail, ...fields }); + return Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const readChecks = provider.getChangeRequestChecks; + if (readChecks === undefined) return yield* Effect.die("checks read missing"); + for (const read of [readChecks, provider.getChangeRequest]) { + const error = yield* read(requiredChecksInput).pipe(Effect.flip); + assert.strictEqual(error.reason, reason); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(requiredChecksLayer(Effect.fail(cause)))); +}); + it.effect("opens Forgejo Actions check links on the pull request's server", () => Effect.gen(function* () { const provider = yield* ForgejoPullRequestProvider.make; @@ -194,31 +355,33 @@ it.effect("opens Forgejo Actions check links on the pull request's server", () = target_url, updated_at: "2026-10-11T00:00:00Z", }); - const body = input.path.endsWith("pulls/1") - ? { - number: 1, - title: "Checks", - body: "", - html_url: "https://forgejo.test/git/acme/web/pulls/1", - user: null, - state: "open", - merged: false, - head: { ref: "feature", sha: "head", repo: null }, - base: { ref: "main", sha: "base", repo: null }, - created_at: "2026-10-11T00:00:00Z", - updated_at: "2026-10-11T00:00:00Z", - closed_at: null, - merged_at: null, - labels: [], - } - : input.path.endsWith("page=1") - ? [ - // Forgejo includes any sub-path mount in the relative links it reports. - status("actions", "/git/acme/web/actions/runs/7/jobs/2"), - status("external", "https://ci.example/build/9"), - status("none", null), - ] - : []; + const body = input.path.endsWith("branches/main") + ? {} + : input.path.endsWith("pulls/1") + ? { + number: 1, + title: "Checks", + body: "", + html_url: "https://forgejo.test/git/acme/web/pulls/1", + user: null, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-10-11T00:00:00Z", + updated_at: "2026-10-11T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + } + : input.path.endsWith("page=1") + ? [ + // Forgejo includes any sub-path mount in the relative links it reports. + status("actions", "/git/acme/web/actions/runs/7/jobs/2"), + status("external", "https://ci.example/build/9"), + status("none", null), + ] + : []; return encodeJsonEffect(body).pipe(Effect.orDie, Effect.map(processOutput)); }, }), @@ -297,6 +460,9 @@ it.effect.each([ case "repos/maria/project/statuses/head": response = []; break; + case "repos/maria/project/branches/main": + response = { name: "main", protected: false }; + break; default: return Effect.die(`Unexpected Forgejo request: ${input.path}`); } diff --git a/packages/source-control-forgejo/src/server/forgejoRequiredChecks.ts b/packages/source-control-forgejo/src/server/forgejoRequiredChecks.ts new file mode 100644 index 000000000000..cc0727cf61c8 --- /dev/null +++ b/packages/source-control-forgejo/src/server/forgejoRequiredChecks.ts @@ -0,0 +1,52 @@ +import * as Schema from "effect/Schema"; +import type { PullRequestCheck } from "@t3tools/contracts"; + +// The branch endpoint reports the governing protection rule with repository read access. +export const ForgejoBranchStatusChecks = Schema.Struct({ + enable_status_check: Schema.optional(Schema.Boolean), + status_check_contexts: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), +}); + +// Forgejo compiles each context with gobwas/glob and no separator, so `*` and `**` both match any +// run of characters, `/` included, and `?` matches one character. Classes, alternation and escapes +// are not handled. +const UNSUPPORTED_GLOB = /[[\]{}\\]/; +const contextPattern = (glob: string) => + new RegExp( + `^${Array.from(glob, (char) => + char === "*" ? ".*" : char === "?" ? "." : char.replace(/[.+^$()|]/g, "\\$&"), + ).join("")}$`, + "su", + ); + +export function markRequiredForgejoChecks( + checks: ReadonlyArray, + branch: typeof ForgejoBranchStatusChecks.Type, +): ReadonlyArray { + const contexts = branch.enable_status_check ? (branch.status_check_contexts ?? []) : []; + if (contexts.length === 0) return checks; + const unsupported = [...new Set(contexts.filter((context) => UNSUPPORTED_GLOB.test(context)))]; + if (unsupported.length > 0) { + // Keep all observed checks gating, but never pass a policy we cannot evaluate. + return [ + ...checks, + ...unsupported.map((name) => ({ + name, + status: "pending" as const, + description: "Required check pattern cannot be evaluated.", + url: null, + })), + ]; + } + const patterns = [...new Set(contexts)].map((name) => ({ name, pattern: contextPattern(name) })); + const marked = checks.map((check) => + patterns.some(({ pattern }) => pattern.test(check.name)) ? { ...check, required: true } : check, + ); + // Forgejo keeps an unmatched required pattern pending, including wildcards. + const missing = patterns.flatMap(({ name, pattern }) => + !name.trim() || checks.some((check) => pattern.test(check.name)) + ? [] + : [{ name, status: "pending", description: null, url: null, required: true } as const], + ); + return [...marked, ...missing]; +} From 41fce9ea17d23f284d89cabb65edb5582074cdf7 Mon Sep 17 00:00:00 2001 From: Rookmate <14072042+rookmate@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:05:24 +0400 Subject: [PATCH 033/458] fix(server): `t3 update` and `t3 service restart` run from inside T3 no longer leave the service stopped (#14361) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- apps/server/src/cli/connect.test.ts | 74 +++++++++++++++++- apps/server/src/cli/connect.ts | 40 ++++++---- apps/server/src/cli/service.test.ts | 82 +++++++++++++++++-- apps/server/src/cli/service.ts | 79 ++++++++++++------- apps/server/src/cli/update.test.ts | 92 +++++++++++++++++++++- apps/server/src/cli/update.ts | 17 ++-- apps/server/src/cloud/bootService.test.ts | 95 +++++++++++++++++++++-- apps/server/src/cloud/bootService.ts | 79 +++++++++++++++---- 8 files changed, 475 insertions(+), 83 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index f7bbcd23e675..72a17f56e93e 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,3 +1,5 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -7,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as References from "effect/References"; import * as Terminal from "effect/Terminal"; @@ -15,8 +18,9 @@ import { acquireRelayClientForLink, headlessSessionConfig, reportCloudDisconnectResults, + reportServiceOnboarding, } from "./connect.ts"; -import { recoverServiceOnboardingOffer } from "./service.ts"; +import { offerServiceDuringOnboarding, recoverServiceOnboardingOffer } from "./service.ts"; const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); @@ -40,7 +44,7 @@ it.effect("detects headless operation from individual SSH config values", () => it.effect("treats cancelling optional background setup as a successful skip", () => Effect.gen(function* () { const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); - assert.isFalse(result); + assert.equal(result, "skipped"); }), ); @@ -49,10 +53,74 @@ it.effect("keeps a successful connection when a remote service update is pending const result = yield* recoverServiceOnboardingOffer( Effect.fail(new BootService.BootServiceUpdatePendingError()), ); - assert.isFalse(result); + assert.equal(result, "skipped"); }), ); +it.effect("reports pending onboarding until the handed-off service is ready", () => + Effect.gen(function* () { + const input = yield* Queue.make(); + yield* Queue.offer(input, { + input: Option.some("\r"), + key: { name: "return", ctrl: false, meta: false, shift: false }, + }); + const terminal = Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.succeed(Queue.asDequeue(input)), + readLine: Effect.die("unexpected line input"), + display: () => Effect.void, + }); + const plan = { + program: ["/test/t3", "__service-launcher"], + baseDir: "/test", + unitPath: "/test/t3code.service", + logPath: "/test/boot-service.log", + handedOff: true, + }; + let current = false; + const service = BootService.BootService.of({ + status: Effect.sync(() => ({ + supported: true, + installed: true, + current, + unitPath: plan.unitPath, + logPath: plan.logPath, + })), + install: () => Effect.succeed(plan), + restart: Effect.die("unexpected restart"), + uninstall: Effect.die("unexpected uninstall"), + withStopped: () => Effect.die("unexpected maintenance"), + }); + const lines: string[] = []; + const onboard = recoverServiceOnboardingOffer(offerServiceDuringOnboarding).pipe( + Effect.flatMap(reportServiceOnboarding), + Effect.provideService(BootService.BootService, service), + Effect.provideService(Terminal.Terminal, terminal), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args) => { + lines.push(args.map(String).join(" ")); + }, + }), + ); + + yield* onboard; + const output = lines.join("\n"); + assert.notInclude(output, "Background service ready"); + assert.notInclude(output, "Start the server"); + assert.include(output, "Background service restarting"); + assert.include(output, plan.logPath); + + current = true; + lines.length = 0; + yield* onboard; + assert.include(lines.join("\n"), "Background service ready"); + assert.notInclude(lines.join("\n"), "restarting"); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("does not install the relay client when the user declines the managed download", () => Effect.gen(function* () { let installCalls = 0; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 9de28623c318..9cc59335115b 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -45,7 +45,6 @@ import * as ExternalLauncher from "../process/externalLauncher.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; -import { offerServiceDuringOnboarding, recoverServiceOnboardingOffer } from "./service.ts"; import * as CliService from "./service.ts"; const jsonFlag = Flag.Boolean("json").pipe( @@ -685,6 +684,30 @@ const connectLogoutCommand = Command.make("logout", { ), ); +export const reportServiceOnboarding = Effect.fn("cli.connect.report_service_onboarding")( + function* (outcome: CliService.ServiceOnboardingOutcome) { + if (outcome === "pending") { + yield* Console.log( + "\nBackground service restarting\n\nThe server establishes the T3 Connect link once it is back. Check the log above if it does not come back.", + ); + return; + } + if (outcome === "ready") { + const platform = yield* HostProcess.Platform; + yield* Console.log( + platform === "darwin" + ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." + : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", + ); + return; + } + const serveCommand = yield* resolveCliCommand("serve"); + yield* Console.log( + `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`, + ); + }, +); + export const connectCommand = Command.make("connect", { ...projectLocationFlags, headless: headlessFlag, @@ -701,19 +724,8 @@ export const connectCommand = Command.make("connect", { } yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`); - const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); - if (background) { - const platform = yield* HostProcess.Platform; - yield* Console.log( - platform === "darwin" - ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." - : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", - ); - return; - } - const serveCommand = yield* resolveCliCommand("serve"); - yield* Console.log( - `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`, + yield* reportServiceOnboarding( + yield* CliService.recoverServiceOnboardingOffer(CliService.offerServiceDuringOnboarding), ); }), ), diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 1f1b70dce8ad..dbd0653647ba 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -3,6 +3,7 @@ import { assert, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -97,7 +98,10 @@ it("reports a newer installed service and tells the CLI to catch up to it", () = const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; -function makeTestService(serviceStatus: BootService.BootServiceStatus) { +function makeTestService( + serviceStatus: BootService.BootServiceStatus, + restartOutcome: "restarted" | "queued" = "restarted", +) { const installOptions: Array[0]> = []; const restarts: Array = []; const service = BootService.BootService.of({ @@ -105,7 +109,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { withStopped: (operation) => operation, restart: Effect.sync(() => { restarts.push(true); - return serviceStatus.installed; + return serviceStatus.installed ? restartOutcome : "skipped"; }), install: (options) => Effect.sync(() => { @@ -146,6 +150,74 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); + it.effect("restart from inside the service says the restart is only queued", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service } = makeTestService({ ...status, installedVersion: "0.0.30" }, "queued"); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + const lines: Array = []; + const testConsole = { + ...globalThis.console, + log: (...args: ReadonlyArray) => { + lines.push(args.map(String).join(" ")); + }, + } satisfies Console.Console; + + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "restart", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(Console.Console, testConsole), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + const output = lines.join("\n"); + expect(output).not.toContain("Restarted"); + expect(output).toContain("Queued a restart of the T3 Code service on t3@0.0.30"); + expect(output).toContain(`Logs: ${status.logPath}`); + }), + ); + + it.effect("install reports a handed-off switch as pending", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service } = makeTestService({ ...status, current: false }); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, { + ...service, + install: (options) => + service.install(options).pipe(Effect.map((plan) => ({ ...plan, handedOff: true }))), + }), + ); + const lines: string[] = []; + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "install", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args) => { + lines.push(args.map(String).join(" ")); + }, + }), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + const output = lines.join("\n"); + expect(output).not.toMatch(/(?:Installed|Updated) T3 Code service/); + expect(output).toContain("separate systemd unit"); + expect(output).toContain(status.logPath); + }), + ); + it.effect.each(["install", "update"] as const)( "%s refuses a downgrade before changing the service", (command) => @@ -247,7 +319,7 @@ it.effect("leaves a newer service unchanged during onboarding without prompting" Effect.provide(NodeServices.layer), ); - expect(ready).toBe(false); + expect(ready).toBe("skipped"); expect(installOptions).toEqual([]); }), ); @@ -263,7 +335,7 @@ it.effect("keeps onboarding successful when a newer version appears before insta ), ); - expect(ready).toBe(false); + expect(ready).toBe("skipped"); }), ); @@ -272,6 +344,6 @@ it.effect("keeps the manual-server fallback when background prerequisites fail", const ready = yield* recoverServiceOnboardingOffer( Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })), ); - expect(ready).toBe(false); + expect(ready).toBe("skipped"); }), ); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index abb9fb1b7116..352fa86756c9 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -58,6 +58,15 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op } satisfies ServiceReconcileResult; }); +function formatServiceChange( + result: Extract, +): string { + if (result.plan.handedOff === true) { + return `Handed the T3 Code service switch to t3@${packageJson.version} to a separate systemd unit, since this session runs inside the service and disconnects when it restarts.\nResult: ${result.plan.logPath}`; + } + return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`; +} + export function formatServiceStatus( status: BootService.BootServiceStatus, cliVersion: string, @@ -126,9 +135,7 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ); return; } - yield* Console.log( - `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, - ); + yield* Console.log(formatServiceChange(result)); }), ), ), @@ -149,9 +156,7 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); return; } - yield* Console.log( - `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, - ); + yield* Console.log(formatServiceChange(result)); }), ), ), @@ -167,11 +172,15 @@ const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe Effect.gen(function* () { const service = yield* BootService.BootService; const status = yield* service.status; - const restarted = yield* service.restart; + const outcome = yield* service.restart; + const onVersion = + status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`; yield* Console.log( - restarted - ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.` - : "T3 Code service is not installed.", + outcome === "restarted" + ? `Restarted the T3 Code service${onVersion}.` + : outcome === "queued" + ? `Queued a restart of the T3 Code service${onVersion}. This session runs inside the service and disconnects when it goes down.\nLogs: ${status.logPath}` + : "T3 Code service is not installed.", ); }), ), @@ -207,16 +216,18 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( ), ); +export type ServiceOnboardingOutcome = "ready" | "pending" | "skipped"; + export const offerServiceDuringOnboarding = Effect.gen(function* () { const service = yield* BootService.BootService; const status = yield* service.status; const { supported, installed, current } = status; if (!supported) { - return false; + return "skipped" as const; } if (installed && current) { yield* Console.log("T3 Code is already set up to run in the background on this machine."); - return true; + return "ready" as const; } for (const problem of status.problems ?? []) { yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`); @@ -229,7 +240,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { yield* Console.log( `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, ); - return false; + return "skipped" as const; } const platform = yield* HostProcess.Platform; const wanted = yield* Prompt.run( @@ -245,35 +256,45 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { }), ); if (!wanted) { - return false; + return "skipped" as const; } const result = yield* reconcileService(); if (result.changed) { yield* Console.log( - `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, + result.plan.handedOff === true + ? formatServiceChange(result) + : `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, ); } - return true; + return result.changed && result.plan.handedOff === true + ? ("pending" as const) + : ("ready" as const); }); +const reportServiceSetupFailure = (error: BootService.BootServiceError) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as("skipped" as const), + ); + export const recoverServiceOnboardingOffer = ( - offer: Effect.Effect, + offer: Effect.Effect< + ServiceOnboardingOutcome, + BootService.BootServiceError | Terminal.QuitError, + R + >, ) => offer.pipe( Effect.catchTags({ - QuitError: () => Effect.succeed(false), + QuitError: () => Effect.succeed("skipped" as const), BootServiceUnsupportedError: (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), - BootServiceCommandError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServiceInstallError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServicePrerequisiteError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServiceUpdatePendingError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServiceDowngradeRefusedError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + Console.log(`Skipping background setup: ${error.message}`).pipe( + Effect.as("skipped" as const), + ), + BootServiceCommandError: reportServiceSetupFailure, + BootServiceInstallError: reportServiceSetupFailure, + BootServicePrerequisiteError: reportServiceSetupFailure, + BootServiceUpdatePendingError: reportServiceSetupFailure, + BootServiceDowngradeRefusedError: reportServiceSetupFailure, }), ); diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 4d784f04c42c..67ae3bfa5321 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -1,12 +1,23 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Console from "effect/Console"; 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 HostProcess from "@t3tools/shared/HostProcess"; +import { Command } from "effect/cli"; +import { afterEach, vi } from "vite-plus/test"; -import { repointLauncher, resolveLauncherPath } from "./update.ts"; +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { pinnedRuntimePaths } from "../cloud/pinnedRuntime.ts"; +import { repointLauncher, resolveLauncherPath, updateCommand } from "./update.ts"; + +afterEach(() => vi.restoreAllMocks()); it.layer(NodeServices.layer)("t3 update launcher", (it) => { it.effect("repoints a symlink that lives in a runtime versions tree", () => @@ -101,3 +112,78 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); }); + +it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 update service", (it) => { + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "reports the service switch as pending after installing the target runtime", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-service-" }); + const targetVersion = "999.0.0"; + const runtime = pinnedRuntimePaths(path, baseDir, targetVersion, "linux"); + yield* fs.makeDirectory(runtime.versionDir, { recursive: true }); + yield* fs.writeFileString( + runtime.entryPath, + `#!/bin/sh\nprintf 't3 v${targetVersion}\\n'\n`, + ); + yield* fs.chmod(runtime.entryPath, 0o755); + yield* fs.writeFileString(runtime.sentinelPath, `${targetVersion}\n`); + const logPath = path.join(baseDir, "userdata/logs/boot-service.log"); + const unitPath = path.join(baseDir, "t3code.service"); + const installs: Array[0]> = []; + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, { + status: Effect.succeed({ + supported: true, + installed: true, + current: true, + installedVersion: packageJson.version, + installedBaseDir: baseDir, + logPath, + unitPath, + }), + install: (options) => + Effect.sync(() => { + installs.push(options); + return { + program: [runtime.entryPath], + baseDir, + logPath, + unitPath, + handedOff: true, + }; + }), + restart: Effect.die("unexpected restart"), + uninstall: Effect.die("unexpected uninstall"), + withStopped: () => Effect.die("unexpected maintenance"), + }), + ); + const lines: string[] = []; + yield* Command.runWith(updateCommand, { version: packageJson.version })([ + targetVersion, + "--yes", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args) => { + lines.push(args.map(String).join(" ")); + }, + }), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + expect(installs).toEqual([{ allowDowngrade: false, start: true }]); + const output = lines.join("\n"); + expect(output).not.toContain("Background service restarted"); + expect(output).toContain(`Background service switching to ${targetVersion}`); + expect(output).toContain(logPath); + }), + ); +}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 0014f7bd6ce9..3d18f8015a29 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -490,18 +490,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { targetEntryPath: runtime.entryPath, }); - // The service switch runs in this process against the target version: the downloaded runtime - // has already proven it runs (the `--version` check above), and doing it here rather than - // through the target's own CLI means a downgrade to a version without today's commands still - // works. - let serviceUpdated = false; + // Use today's service manager for the target version. Inside the service it hands off to + // the target CLI, so stopping this process cannot interrupt the switch. + let servicePlan: BootService.BootServicePlan | undefined; if (serviceInstalled && !serviceCurrent) { yield* Console.log( restartService ? "Restarting the background service..." : "Updating the background service...", ); - yield* BootService.BootService.pipe( + servicePlan = yield* BootService.BootService.pipe( Effect.flatMap((target) => target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), ), @@ -519,7 +517,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { }), ), ); - serviceUpdated = restartService; } progress.success(`Installed T3 Code ${targetVersion}`); @@ -528,7 +525,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { } else { yield* Console.log(` Run ${runtime.entryPath}\n`); } - if (serviceUpdated) { + if (servicePlan?.handedOff === true) { + yield* Console.log( + ` Background service switching to ${targetVersion} from a separate systemd unit, since this session runs inside it and disconnects when it restarts. Result: ${servicePlan.logPath}`, + ); + } else if (servicePlan !== undefined && restartService) { yield* Console.log(` Background service restarted on ${targetVersion}`); } else if (serviceInstalled && serviceCurrent) { yield* Console.log(` Background service already on ${targetVersion}`); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4fb6d93babd2..bbaf441383fb 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -140,6 +140,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; + const invocations: ProcessRunner.ProcessRunInput[] = []; const timeouts = new Map(); const control: { failCommand: string | undefined; @@ -159,6 +160,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ) { const command = `${input.command} ${input.args.join(" ")}`; commands.push(command); + invocations.push(input); timeouts.set(command, input.timeout); const failed = command === control.failCommand; if (!failed && command === "loginctl enable-linger --no-ask-password 501") @@ -205,6 +207,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( environmentPath: string | undefined = installerPath, cliVersion = "1.2.3", serviceBaseDir = baseDir, + insideService = false, ) => Effect.gen(function* () { const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform); @@ -235,6 +238,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ...(environmentPath === undefined || environmentPath === "" ? {} : { PATH: environmentPath }), + ...(insideService ? { T3_BOOT_SERVICE_UNIT: "t3code.service" } : {}), }, }), ), @@ -242,7 +246,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ), ); const service = yield* makeService(); - return { service, makeService, fs, statePath, commands, timeouts, control, runtime }; + return { service, makeService, fs, statePath, commands, invocations, timeouts, control, runtime }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -507,7 +511,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(status.problems).toContain("restart-pending"); commands.length = 0; - expect(yield* newer.restart).toBe(true); + expect(yield* newer.restart).toBe("restarted"); expect((yield* newer.status).problems).not.toContain("restart-pending"); expect((yield* newer.status).current).toBe(true); }), @@ -625,11 +629,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restart stops and starts an installed service, and is a no-op otherwise", () => Effect.gen(function* () { const { service, commands } = yield* makeHarness(); - expect(yield* service.restart).toBe(false); + expect(yield* service.restart).toBe("skipped"); yield* service.install(); commands.length = 0; - expect(yield* service.restart).toBe(true); + expect(yield* service.restart).toBe("restarted"); expect( commands.filter( (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), @@ -652,7 +656,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" }); const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3")); - expect(yield* other.restart).toBe(false); + expect(yield* other.restart).toBe("skipped"); expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]); }), ); @@ -678,6 +682,87 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("hands the whole install to systemd without changing the running service", () => + Effect.gen(function* () { + const { fs, commands, invocations, control, makeService } = yield* makeHarness(); + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-handoff-" }); + const baseDir = path.join(home, "T3 Data 100%", "${UNSET}", "$literal"); + const outside = yield* makeService(undefined, "1.2.3", baseDir); + const plan = yield* outside.install(); + const statePath = path.join(baseDir, "runtime", "service-state.json"); + const state = yield* fs.readFileString(statePath); + const unit = yield* fs.readFileString(plan.unitPath); + const inside = yield* makeService(undefined, "1.2.4", baseDir, true); + commands.length = 0; + invocations.length = 0; + + const result = yield* inside.install({ allowDowngrade: true }); + + expect(yield* fs.readFileString(statePath)).toBe(state); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); + expect(commands).not.toContain("systemctl --user stop t3code.service"); + expect(result).toMatchObject({ handedOff: true, logPath: plan.logPath }); + const handoffs = invocations.filter(({ command }) => command === "systemd-run"); + expect(handoffs).toHaveLength(1); + expect(handoffs[0]?.args).toEqual([ + "--user", + "--collect", + "--quiet", + `--property=StandardOutput=append:${plan.logPath}`, + `--property=StandardError=append:${plan.logPath}`, + "--setenv=T3_BOOT_SERVICE_UNIT=", + pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath, + "service", + "install", + "--base-dir", + path.join(home, "T3 Data 100%", "$${UNSET}", "$$literal"), + "--allow-downgrade", + ]); + + const handoff = handoffs[0]!; + control.failCommand = `${handoff.command} ${handoff.args.join(" ")}`; + const error = yield* inside.install({ allowDowngrade: true }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "BootServiceCommandError", exitCode: 1 }); + expect(yield* fs.readFileString(statePath)).toBe(state); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); + expect(control.active).toBe(true); + expect(commands).not.toContain("systemctl --user stop t3code.service"); + }), + ); + + it.effect( + "queues an in-service restart without stopping the caller, even on handoff failure", + () => + Effect.gen(function* () { + const { service, fs, statePath, commands, control, makeService } = yield* makeHarness(); + const plan = yield* service.install(); + const state = yield* fs.readFileString(statePath); + const marker = `${plan.baseDir}/runtime/${SERVICE_RESTART_PENDING_FILE}`; + yield* fs.writeFileString(marker, "1.2.3\n"); + const inside = yield* makeService(undefined, "1.2.3", undefined, true); + commands.length = 0; + control.failCommand = "systemctl --user daemon-reload"; + + const error = yield* inside.restart.pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "BootServiceCommandError", exitCode: 1 }); + expect(commands).toEqual(["systemctl --user daemon-reload"]); + expect(control.active).toBe(true); + + control.failCommand = undefined; + commands.length = 0; + const outcome = yield* inside.restart; + expect(commands).toEqual([ + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + "systemctl --user restart --no-block t3code.service", + ]); + expect(outcome).toBe("queued"); + expect(yield* fs.readFileString(statePath)).toBe(state); + expect(yield* fs.readFileString(marker)).toBe("1.2.3\n"); + }), + ); + it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 00ab464bea41..ac82b3bb8280 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -75,6 +75,8 @@ export interface BootServicePlan { readonly baseDir: string; readonly logPath: string; readonly unitPath: string; + /** The manager accepted a detached install; its outcome is written to `logPath`. */ + readonly handedOff?: boolean; } /** Pure renderer: service units cannot rely on the user's shell or PATH. */ @@ -206,6 +208,8 @@ export interface BootServiceManager { readonly stop: ReadonlyArray; readonly activate: ReadonlyArray; readonly restart: ReadonlyArray; + /** Queue a restart owned by the manager, since stopping would kill the caller. */ + readonly handoff?: ReadonlyArray; readonly deactivate: ReadonlyArray; readonly finalize: ReadonlyArray; } @@ -221,6 +225,18 @@ function systemdManager(input: { "user", BOOT_SERVICE_UNIT_FILE, ); + const prepareActivation = [ + { + step: "reloading systemd user units", + command: "systemctl", + args: ["--user", "daemon-reload"], + }, + { + step: "enabling the service", + command: "systemctl", + args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], + }, + ]; return { kind: "systemd", unitPath, @@ -234,16 +250,7 @@ function systemdManager(input: { }, ], activate: [ - { - step: "reloading systemd user units", - command: "systemctl", - args: ["--user", "daemon-reload"], - }, - { - step: "enabling the service", - command: "systemctl", - args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], - }, + ...prepareActivation, { step: "starting the service", command: "systemctl", @@ -257,6 +264,14 @@ function systemdManager(input: { args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], + handoff: [ + ...prepareActivation, + { + step: "restarting the service", + command: "systemctl", + args: ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE], + }, + ], deactivate: [ { step: "stopping the service", @@ -494,10 +509,11 @@ export class BootService extends Context.Service< }) => Effect.Effect; /** * Only when the unit serves this base dir: the unit name is per user, so - * another home's service is left alone. Resolves false when nothing was - * restarted. + * another home's service is left alone, which resolves `skipped`. From + * inside the service the restart can only be `queued` with the service + * manager, since the stop ends this process. */ - readonly restart: Effect.Effect; + readonly restart: Effect.Effect<"restarted" | "queued" | "skipped", BootServiceError>; /** * Run `operation` while this home's installed service is stopped, then * start it again when it was enabled (systemd) or loaded (launchd) @@ -533,6 +549,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); const homeDir = yield* Config.String("HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.String("PATH").pipe(Config.withDefault("")); + const runningInsideService = + (yield* Config.String(BOOT_SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === + BOOT_SERVICE_UNIT_FILE; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; @@ -781,6 +800,28 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // middle of a remote update, which is the one time it writes the state file // itself. const start = options?.start !== false; + if (installed && start && runningInsideService && manager.kind === "systemd") { + // Stopping the unit from inside it kills this process before the start. + // A transient unit runs the same install from outside, stop first, so it + // still serialises with a remote update the launcher may be starting. + yield* runStep("handing the install to a transient unit", "systemd-run", [ + "--user", + "--collect", + "--quiet", + // Transient properties reach systemd literally, unlike unit-file specifiers. + `--property=StandardOutput=append:${logPath}`, + `--property=StandardError=append:${logPath}`, + `--setenv=${BOOT_SERVICE_UNIT_ENV}=`, + runtimePaths.entryPath, + "service", + "install", + "--base-dir", + // systemd expands $VAR in arguments (not in the executable path). + input.baseDir.replaceAll("$", () => "$$"), + ...(options?.allowDowngrade === true ? ["--allow-downgrade"] : []), + ]); + return { ...plan, handedOff: true }; + } if (installed && start) { yield* runSteps(manager.stop); } @@ -856,20 +897,26 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const restart: BootService["Service"]["restart"] = Effect.gen(function* () { const manager = yield* requireManager; const unit = yield* fs.readFileString(unitPath).pipe(Effect.option); - if (Option.isNone(unit)) return false; + if (Option.isNone(unit)) return "skipped" as const; const installedBaseDir = bootServiceBaseDirOf(unit.value); if ( installedBaseDir === undefined || path.resolve(installedBaseDir) !== path.resolve(input.baseDir) ) { - return false; + return "skipped" as const; + } + // Writes no state, and the launcher lets an update transition in flight + // finish before the queued stop tears it down. + if (runningInsideService && manager.handoff !== undefined) { + yield* runSteps(manager.handoff); + return "queued" as const; } yield* runSteps(manager.stop); yield* runSteps(manager.activate).pipe( Effect.tapError(() => runSteps(manager.restart).pipe(Effect.ignore)), ); yield* fs.remove(restartPendingPath, { force: true }); - return true; + return "restarted" as const; }).pipe( Effect.mapError((cause) => cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, From cf55896339f17d874ff6885afcf8b029f6878997 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:35:29 +0530 Subject: [PATCH 034/458] perf(mobile): streamed chunks cost about 8x less JS on long threads (#19135) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../src/features/threads/ThreadFeed.tsx | 3 + apps/mobile/src/lib/threadActivity.test.ts | 238 +++++++++++++ apps/mobile/src/lib/threadActivity.ts | 327 ++++++++++++++++-- .../src/state/use-thread-composer-state.ts | 4 +- .../state/orchestrationV2Projection.test.ts | 68 ++++ .../src/state/orchestrationV2Projection.ts | 51 ++- 6 files changed, 649 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ff5bf3147e02..293f6aceac63 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -174,6 +174,7 @@ import { resolveMarkdownLinkPresentation, } from "../../lib/markdownLinks"; import { + createThreadFeedCache, failedFeedRunIds, deriveThreadFeedPresentation, threadFeedRunIsUnsettled, @@ -1681,6 +1682,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); + const [feedCache] = useState(createThreadFeedCache); const previousLatestTurnRef = useRef(props.latestRun); const userScrollSettleTimerRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); @@ -2101,6 +2103,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ), props.activeWorkStartedAt, props.runlessWorkActive ?? false, + feedCache, ), props.feed, props.queuedMessages, diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f9fccc8a394d..b443c0e3f7a1 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -28,11 +28,14 @@ import { workEntryRowLabel, isContextHandoffActivityGroup, buildThreadFeed, + createThreadFeedCache, deriveThreadFeedPresentation, threadFeedActivityIsVisible, threadFeedRunIsUnsettled, type ThreadFeedActivity, + type ThreadFeedCache, type ThreadFeedEntry, + type ThreadFeedLatestRun, togglePendingUserInputOptionSelection, setPendingUserInputCustomAnswer, isPendingUserInputOptionSelected, @@ -2904,3 +2907,238 @@ describe("MCP apps", () => { expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false); }); }); + +describe("streamed feed updates", () => { + const runs = [RunId.make("run-1"), RunId.make("run-2"), RunId.make("run-3")] as const; + const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`; + const item = (id: string, second: number, run: RunId) => ({ + ...base(id, at(second), second), + runId: run, + }); + const user = ( + id: string, + second: number, + run: RunId, + ): Extract => ({ + ...item(id, second, run), + type: "user_message", + messageId: MessageId.make(`message-${id}`), + createdBy: "user", + creationSource: "mobile", + inputIntent: "turn_start", + text: `Prompt ${id}`, + attachments: [], + }); + const reply = (id: string, second: number, run: RunId, text = "Done", streaming = false) => + ({ + ...item(id, second, run), + ...(streaming ? { status: "running" as const, completedAt: null } : {}), + type: "assistant_message", + messageId: MessageId.make(`message-${id}`), + text, + streaming, + }) satisfies OrchestrationV2TurnItem; + const shell = (id: string, second: number, run: RunId): OrchestrationV2TurnItem => ({ + ...item(id, second, run), + type: "command_execution", + input: `echo ${id}`, + output: id, + exitCode: 0, + }); + const requestId = RuntimeRequestId.make("request-color"); + const question = (answered: boolean): OrchestrationV2TurnItem => ({ + ...item("question", 12, runs[1]), + type: "user_input_request", + requestId, + questions: [], + ...(answered + ? { questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} } } + : {}), + }); + const answer: OrchestrationV2TurnItem = { + ...user("answer", 40, runs[1]), + messageId: MessageId.make(`async-answer:${requestId}`), + }; + const delegation: OrchestrationV2TurnItem = { + ...item("delegation", 3, runs[0]), + type: "dynamic_tool", + toolName: "t3-code.delegate_task", + input: { task: "Check" }, + output: { taskId: "child" }, + }; + const lateChild: OrchestrationV2TurnItem = { + ...item("late-child", 30, runs[0]), + type: "subagent", + subagentId: NodeId.make("child"), + origin: "app_owned", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: sourceThreadId, + prompt: "Check", + result: "Fine", + }; + const resumed: OrchestrationV2TurnItem = { + ...lateChild, + ...item("resumed", 3, runs[2]), + subagentId: NodeId.make("resumed"), + origin: "provider_native", + childThreadId: null, + }; + const history: OrchestrationV2TurnItem[] = [ + user("u1", 1, runs[0]), + shell("s1", 2, runs[0]), + delegation, + reply("a1", 4, runs[0]), + user("u2", 10, runs[1]), + shell("s2", 11, runs[1]), + question(false), + reply("a2", 13, runs[1]), + user("u3", 20, runs[2]), + shell("s3", 21, runs[2]), + ]; + const running = { + runId: runs[2], + status: "running" as const, + startedAt: at(20), + completedAt: null, + }; + const settled = { ...running, status: "completed" as const, completedAt: at(50) }; + // Each step is what one streamed event leaves behind, including the edits + // that reach back into earlier runs. + const steps: ReadonlyArray<{ + readonly items: ReadonlyArray; + readonly latestRun: ThreadFeedLatestRun; + }> = [ + { items: [...history, reply("a3", 22, runs[2], "W", true)], latestRun: running }, + { items: [...history, reply("a3", 22, runs[2], "Wor", true)], latestRun: running }, + { + items: [...history, reply("a3", 22, runs[2], "Working", true), shell("s4", 23, runs[2])], + latestRun: running, + }, + { + items: [ + ...history, + reply("a3", 22, runs[2], "Working", true), + shell("s4", 23, runs[2]), + lateChild, + ], + latestRun: running, + }, + { + items: [ + ...history.map((entry) => (entry.id === "question" ? question(true) : entry)), + reply("a3", 22, runs[2], "Working", true), + shell("s4", 23, runs[2]), + lateChild, + answer, + ], + latestRun: running, + }, + { + items: [ + ...history.slice(0, 9), + shell("inserted", 20, runs[2]), + ...history.slice(9), + reply("a3", 22, runs[2], "Working", true), + shell("s4", 23, runs[2]), + ], + latestRun: running, + }, + { + items: [...history, reply("a3", 22, runs[2], "Working on it"), shell("s4", 23, runs[2])], + latestRun: running, + }, + { + items: [...history, reply("a3", 22, runs[2], "Working on it"), shell("s4", 23, runs[2])], + latestRun: settled, + }, + { + items: [ + ...history, + reply("a3", 22, runs[2], "Working on it"), + shell("s4", 23, runs[2]), + shell("late-s1", 45, runs[0]), + ], + latestRun: settled, + }, + // The first run's fold above was timed to the late item; dropping it must reach back too. + { + items: [...history, reply("a3", 22, runs[2], "Working on it"), shell("s4", 23, runs[2])], + latestRun: settled, + }, + // The latest run resumes a subagent from the first run: the item moves to + // the latest run but keeps its place, so it folds with the first run. + { + items: [ + ...history.slice(0, 3), + resumed, + ...history.slice(3), + reply("a3", 22, runs[2], "Working on it"), + shell("s4", 23, runs[2]), + ], + latestRun: running, + }, + { + items: [ + ...history.slice(0, 3), + resumed, + ...history.slice(3), + reply("a3", 22, runs[2], "Working on it"), + shell("s4", 23, runs[2]), + reply("a4", 24, runs[2], "Still", true), + ], + latestRun: running, + }, + ]; + // Like the projection reducer, an unchanged item at an unchanged position keeps its row. + const rowsByItem = new Map< + OrchestrationV2TurnItem, + Map + >(); + const row = (entry: OrchestrationV2TurnItem, position: number) => { + const byPosition = rowsByItem.get(entry) ?? new Map(); + rowsByItem.set(entry, byPosition); + const cached = byPosition.get(position) ?? projected(entry, position); + byPosition.set(position, cached); + return cached; + }; + const present = ( + step: (typeof steps)[number], + expandedRunIds: ReadonlySet, + cache?: ThreadFeedCache, + ) => + deriveThreadFeedPresentation( + buildThreadFeed(step.items.map(row), { cache }), + step.latestRun, + expandedRunIds, + new Set(), + step.latestRun.status === "running" ? at(20) : null, + false, + cache, + ); + + it.each([ + { folds: "collapsed", expandedRunIds: new Set() }, + { folds: "expanded", expandedRunIds: new Set(runs) }, + ])("matches a fresh build after every update with $folds runs", ({ expandedRunIds }) => { + const cache = createThreadFeedCache(); + const streamed = steps.map((step) => present(step, expandedRunIds, cache)); + const fresh = steps.map((step) => present(step, expandedRunIds)); + // Closures on activities are not part of what a row shows. + const values = (rows: ReadonlyArray) => JSON.parse(JSON.stringify(rows)); + for (const [index, rows] of streamed.entries()) { + expect(values(rows), `step ${index}`).toEqual(values(fresh[index]!)); + } + }); + + it("hides a delegation once its child reaches back into the earlier run", () => { + const showsDelegation = (step: (typeof steps)[number]) => + buildThreadFeed(step.items.map(row)).some( + (entry) => + entry.type === "activity-group" && + entry.activities.some((activity) => activity.projectedItem.item.id === "delegation"), + ); + expect(showsDelegation(steps[2]!)).toBe(true); + expect(showsDelegation(steps[3]!)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 631c2639357b..ff68fa56f2e8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -887,16 +887,15 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean { ); } -function groupAdjacentActivities(entries: ReadonlyArray): ThreadFeedEntry[] { - const childrenByRun = new Map>(); - for (const entry of entries) { - if (entry.type !== "activity") continue; - const item = entry.activity.projectedItem.item; - if (item.type !== "subagent" || item.origin !== "app_owned" || item.runId === null) continue; - const children = childrenByRun.get(item.runId) ?? new Set(); - children.add(item.subagentId); - childrenByRun.set(item.runId, children); - } +/** + * `onRestart` hears each entry after which no group is open, with the grouped + * length so far: the entries before it group the same whatever follows. + */ +function groupAdjacentActivities( + entries: ReadonlyArray, + childrenByRun: ReadonlyMap>, + onRestart?: (entryIndex: number, groupedLength: number) => void, +): ThreadFeedEntry[] { const grouped: ThreadFeedEntry[] = []; let firstActivityEntry: Extract | null = null; let openGroupActivities: ThreadFeedActivity[] = []; @@ -924,7 +923,8 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th openGroupActivities = []; }; - for (const entry of entries) { + for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 1) { + const entry = entries[entryIndex]!; if (entry.type === "activity") { const item = entry.activity.projectedItem.item; if ( @@ -950,6 +950,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th if (entry.type !== "activity") { flushGroup(); grouped.push(entry); + onRestart?.(entryIndex, grouped.length); continue; } @@ -1245,6 +1246,79 @@ function settleSupersededReasoning( return settled; } +interface ThreadFeedPresentationBuild { + readonly feed: ReadonlyArray; + readonly latestRun: ThreadFeedLatestRun | null; + readonly expandedRunIds: ReadonlySet; + readonly expandedWorkGroupIds: ReadonlySet; + readonly activeWorkStartedAt: string | null; + readonly runlessWorkActive: boolean; + readonly rows: ReadonlyArray; + /** Feed entry `entry` is a user message whose rows start at `rowsLength`. */ + readonly restarts: ReadonlyArray<{ readonly entry: number; readonly rowsLength: number }>; + /** Where each run's entries begin, so a reused prefix never shares a run with the rest. */ + readonly firstEntryByRun: ReadonlyMap; +} + +function latestRunsEqual(left: ThreadFeedLatestRun | null, right: ThreadFeedLatestRun | null) { + return ( + left === right || + (left !== null && + right !== null && + left.runId === right.runId && + left.status === right.status && + left.startedAt === right.startedAt && + left.completedAt === right.completedAt && + left.turnEnded === right.turnEnded) + ); +} + +function feedEntryRunId(entry: ThreadFeedEntry): RunId | null { + return entry.type === "message" ? (entry.message.runId ?? null) : entry.runId; +} + +function isUserMessageEntry(entry: ThreadFeedEntry | undefined): boolean { + return entry?.type === "message" && entry.message.role === "user"; +} + +/** + * A user message starts every fold and is never hidden or grouped, so the + * rows before it depend only on the entries before it, their runs, and the + * shared inputs. Returns the user message entry to restart from, or null to + * present the whole feed. + */ +function presentationRestart( + previous: ThreadFeedPresentationBuild | null, + feed: ReadonlyArray, + latestRun: ThreadFeedLatestRun | null, + expandedRunIds: ReadonlySet, + expandedWorkGroupIds: ReadonlySet, + activeWorkStartedAt: string | null, + runlessWorkActive: boolean, +): { readonly entry: number; readonly rowsLength: number; readonly index: number } | null { + if ( + previous === null || + !latestRunsEqual(previous.latestRun, latestRun) || + previous.activeWorkStartedAt !== activeWorkStartedAt || + previous.runlessWorkActive !== runlessWorkActive || + !setsEqual(previous.expandedRunIds, expandedRunIds) || + !setsEqual(previous.expandedWorkGroupIds, expandedWorkGroupIds) + ) { + return null; + } + const firstChanged = firstChangedIndex(previous.feed, feed); + let index = previous.restarts.length - 1; + while (index >= 0 && previous.restarts[index]!.entry > firstChanged) index -= 1; + const restart = previous.restarts[index]; + if (restart === undefined || !isUserMessageEntry(feed[restart.entry])) return null; + for (let entryIndex = restart.entry; entryIndex < feed.length; entryIndex += 1) { + const runId = feedEntryRunId(feed[entryIndex]!); + const firstEntry = runId === null ? undefined : previous.firstEntryByRun.get(runId); + if (firstEntry !== undefined && firstEntry < restart.entry) return null; + } + return { ...restart, index }; +} + export function deriveThreadFeedPresentation( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, @@ -1252,11 +1326,42 @@ export function deriveThreadFeedPresentation( expandedWorkGroupIds: ReadonlySet = new Set(), activeWorkStartedAt: string | null = null, runlessWorkActive = false, + cache?: ThreadFeedCache, ): ThreadFeedEntry[] { - const retainedFeed = feed.filter( - (entry) => - entry.type !== "run-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", + const previous = cache?.presentation ?? null; + const restart = presentationRestart( + previous, + feed, + latestRun, + expandedRunIds, + expandedWorkGroupIds, + activeWorkStartedAt, + runlessWorkActive, ); + const start = restart?.entry ?? 0; + const result: ThreadFeedEntry[] = + restart === null ? [] : previous!.rows.slice(0, restart.rowsLength); + const restarts = restart === null ? [] : previous!.restarts.slice(0, restart.index); + const firstEntryByRun = new Map(); + if (restart !== null) { + for (const [runId, entry] of previous!.firstEntryByRun) { + if (entry < start) firstEntryByRun.set(runId, entry); + } + } + const tailStart = result.length; + const lastEntryByRun = new Map(); + + const retainedFeed: Array< + Exclude + > = []; + const retainedFeedIndices: number[] = []; + for (let index = start; index < feed.length; index += 1) { + const entry = feed[index]!; + if (entry.type === "run-fold" || entry.type === "work-toggle" || entry.type === "thinking") + continue; + retainedFeed.push(entry); + retainedFeedIndices.push(index); + } const sourceFeed = retainedFeed.map((entry, index) => settleSupersededReasoning(entry, index === retainedFeed.length - 1), ); @@ -1276,8 +1381,17 @@ export function deriveThreadFeedPresentation( for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); } } - const result: ThreadFeedEntry[] = []; - for (const entry of sourceFeed) { + for (let sourceIndex = 0; sourceIndex < sourceFeed.length; sourceIndex += 1) { + const entry = sourceFeed[sourceIndex]!; + const feedIndex = retainedFeedIndices[sourceIndex]!; + if (isUserMessageEntry(entry)) { + restarts.push({ entry: feedIndex, rowsLength: result.length }); + } + const runId = feedEntryRunId(entry); + if (runId !== null) { + if (!firstEntryByRun.has(runId)) firstEntryByRun.set(runId, feedIndex); + lastEntryByRun.set(runId, feedIndex); + } const isActiveTailGroup = isWorking && (activeRunId !== null || runlessWorkActive) && @@ -1356,15 +1470,51 @@ export function deriveThreadFeedPresentation( ) { result.push(thinkingRow(activeWorkStartedAt, activeRunId)); } - return result.map((row, index) => { - if (!isWorkLogFeedRow(row) || !isWorkLogFeedRow(result[index + 1])) return row; + // Reused rows are final: the user message after them never continues a work log. + for (let index = tailStart; index < result.length; index += 1) { + const row = result[index]!; + if (!isWorkLogFeedRow(row) || !isWorkLogFeedRow(result[index + 1])) continue; let continued = continuedWorkLogRows.get(row); if (!continued) { continued = { ...row, continuesWorkLog: true }; continuedWorkLogRows.set(row, continued); } - return continued; + result[index] = continued; + } + // A run that crosses a user message made the rows before it from entries + // after it, so a later build cannot restart there even once those entries + // are gone. Only runs of this build's own entries can cross its restarts. + const crossings = Array.from({ length: feed.length - start + 1 }, () => 0); + for (const [runId, last] of lastEntryByRun) { + const first = firstEntryByRun.get(runId)!; + if (first >= last) continue; + crossings[first - start + 1]! += 1; + crossings[last - start + 1]! -= 1; + } + let crossingRuns = 0; + let crossingIndex = 0; + const cleanRestarts = restarts.filter((candidate) => { + if (candidate.entry < start) return true; + while (crossingIndex <= candidate.entry - start) { + crossingRuns += crossings[crossingIndex]!; + crossingIndex += 1; + } + return crossingRuns === 0; }); + if (cache) { + cache.presentation = { + feed, + latestRun, + expandedRunIds, + expandedWorkGroupIds, + activeWorkStartedAt, + runlessWorkActive, + rows: result, + restarts: cleanRestarts, + firstEntryByRun, + }; + } + return result; } const continuedWorkLogRows = new WeakMap(); @@ -1744,15 +1894,69 @@ export function buildPendingUserInputAnswers( return answers; } +interface ThreadFeedBuild { + readonly rows: ReadonlyArray; + readonly attempts: ReadonlyArray | undefined; + readonly nodes: ReadonlyArray | undefined; + readonly foldedAnswerMessageIds: ReadonlySet; + readonly delegatedChildrenByRun: ReadonlyMap>; + readonly feed: ReadonlyArray; + /** Grouping is empty before `row`: the rows before it produced `feed.slice(0, feedLength)`. */ + readonly restarts: ReadonlyArray<{ readonly row: number; readonly feedLength: number }>; +} + +/** + * A streamed chunk changes the newest rows of a thread that can hold thousands. + * A consumer that keeps one cache across renders lets `buildThreadFeed` and + * `deriveThreadFeedPresentation` keep the unchanged prefix of their last + * result instead of redoing the whole thread. Each feed on screen owns its + * cache, so two threads rendering at once never evict each other. + */ +export interface ThreadFeedCache { + feedBuild: ThreadFeedBuild | null; + presentation: ThreadFeedPresentationBuild | null; +} + +export function createThreadFeedCache(): ThreadFeedCache { + return { feedBuild: null, presentation: null }; +} + +function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) return false; + for (const value of left) if (!right.has(value)) return false; + return true; +} + +function setMapsEqual( + left: ReadonlyMap>, + right: ReadonlyMap>, +): boolean { + if (left.size !== right.size) return false; + for (const [key, values] of left) { + const other = right.get(key); + if (other === undefined || !setsEqual(values, other)) return false; + } + return true; +} + +/** Index of the first entry that differs, or the shorter length when one extends the other. */ +function firstChangedIndex(previous: ReadonlyArray, next: ReadonlyArray): number { + const limit = Math.min(previous.length, next.length); + let index = 0; + while (index < limit && previous[index] === next[index]) index += 1; + return index; +} + export function buildThreadFeed( visibleTurnItems: ReadonlyArray, options?: { readonly anchoredMessages?: ReadonlyArray; readonly attempts?: ReadonlyArray; readonly nodes?: ReadonlyArray; + readonly cache?: ThreadFeedCache; }, ): ThreadFeedEntry[] { - const entries: RawThreadFeedEntry[] = []; + const anchoredMessages = options?.anchoredMessages ?? []; const attemptByRootNodeId = new Map( (options?.attempts ?? []).map((attempt) => [attempt.rootNodeId, attempt] as const), ); @@ -1773,14 +1977,45 @@ export function buildThreadFeed( } return null; }; - const foldedAnswerMessageIds = new Set( - visibleTurnItems.flatMap(({ item }) => - item.type === "user_input_request" && item.questionAnswer - ? [`async-answer:${item.questionAnswer.requestId}`] - : [], - ), - ); - for (const row of visibleTurnItems) { + // Thread-wide facts that change rows anywhere in the feed. A cached prefix + // is reused only while these match the build that produced it. + const foldedAnswerMessageIds = new Set(); + const delegatedChildrenByRun = new Map>(); + for (const { item } of visibleTurnItems) { + if (item.type === "user_input_request" && item.questionAnswer) { + foldedAnswerMessageIds.add(`async-answer:${item.questionAnswer.requestId}`); + } else if (item.type === "subagent" && item.origin === "app_owned" && item.runId !== null) { + const children = delegatedChildrenByRun.get(item.runId) ?? new Set(); + children.add(item.subagentId); + delegatedChildrenByRun.set(item.runId, children); + } + } + + const cache = options?.cache; + const previous = cache?.feedBuild ?? null; + let restartIndex = -1; + if ( + previous !== null && + anchoredMessages.length === 0 && + previous.attempts === options?.attempts && + previous.nodes === options?.nodes && + setsEqual(previous.foldedAnswerMessageIds, foldedAnswerMessageIds) && + setMapsEqual(previous.delegatedChildrenByRun, delegatedChildrenByRun) + ) { + const firstChanged = firstChangedIndex(previous.rows, visibleTurnItems); + restartIndex = previous.restarts.length - 1; + while (restartIndex >= 0 && previous.restarts[restartIndex]!.row > firstChanged) { + restartIndex -= 1; + } + } + const restart = restartIndex === -1 ? null : previous!.restarts[restartIndex]!; + const prefix = restart === null ? [] : previous!.feed.slice(0, restart.feedLength); + const restarts = restart === null ? [] : previous!.restarts.slice(0, restartIndex + 1); + + const entries: RawThreadFeedEntry[] = []; + const entryRows: number[] = []; + for (let index = restart?.row ?? 0; index < visibleTurnItems.length; index += 1) { + const row = visibleTurnItems[index]!; const item = row.item; if (turnItemIsWorkspacePreparation(item)) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; @@ -1792,6 +2027,7 @@ export function buildThreadFeed( const cached = projectedEntriesCache.get(row); if (cached?.attemptId === attemptId) { entries.push(cached.entry); + entryRows.push(index); continue; } const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); @@ -1809,6 +2045,7 @@ export function buildThreadFeed( }; projectedEntriesCache.set(row, { attemptId, entry }); entries.push(entry); + entryRows.push(index); continue; } const app = @@ -1828,6 +2065,7 @@ export function buildThreadFeed( }; projectedEntriesCache.set(row, { attemptId, entry }); entries.push(entry); + entryRows.push(index); continue; } if (item.type === "user_message" || item.type === "assistant_message") { @@ -1862,6 +2100,7 @@ export function buildThreadFeed( }; projectedEntriesCache.set(row, { attemptId, entry }); entries.push(entry); + entryRows.push(index); continue; } const activity = toFeedActivity(row, attemptId); @@ -1874,6 +2113,7 @@ export function buildThreadFeed( }; projectedEntriesCache.set(row, { attemptId, entry }); entries.push(entry); + entryRows.push(index); } const retainedMessageIds = new Set([ ...foldedAnswerMessageIds, @@ -1903,7 +2143,7 @@ export function buildThreadFeed( localMessageEntriesCache.set(message, entry); return entry; }; - for (const message of options?.anchoredMessages ?? []) { + for (const message of anchoredMessages) { if (retainedMessageIds.has(message.id)) continue; retainedMessageIds.add(message.id); const entry = appendLocalMessage(message); @@ -1913,5 +2153,32 @@ export function buildThreadFeed( if (insertionIndex === -1) entries.push(entry); else entries.splice(insertionIndex, 0, entry); } - return groupAdjacentActivities(entries); + const grouped = groupAdjacentActivities( + entries, + delegatedChildrenByRun, + // Anchored messages shift entries away from their rows; such a build is not reused. + anchoredMessages.length > 0 + ? undefined + : (entryIndex, groupedLength) => + restarts.push({ + row: entryRows[entryIndex]! + 1, + feedLength: prefix.length + groupedLength, + }), + ); + const feed = prefix.length === 0 ? grouped : prefix.concat(grouped); + if (cache) { + cache.feedBuild = + anchoredMessages.length > 0 + ? null + : { + rows: visibleTurnItems, + attempts: options?.attempts, + nodes: options?.nodes, + foldedAnswerMessageIds, + delegatedChildrenByRun, + feed, + restarts, + }; + } + return feed; } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index bb2be14282ee..c33cf3267fe6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -55,7 +55,7 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { buildThreadFeed } from "../lib/threadActivity"; +import { buildThreadFeed, createThreadFeedCache } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; import { threadAllowsProviderSwitch } from "./thread-provider-switching"; @@ -245,6 +245,7 @@ export function useThreadComposerState() { // detail is usually present but empty during a worktree checkout, so this // cannot be an either/or with the loaded messages. const pendingCreationMessage = selectedThreadCreation?.message ?? null; + const [feedCache] = useState(createThreadFeedCache); const selectedThreadFeed = (() => { const pendingCreation = pendingCreationMessage !== null && @@ -255,6 +256,7 @@ export function useThreadComposerState() { anchoredMessages: pendingCreation, attempts: selectedThreadAttempts, nodes: selectedThreadNodes, + cache: feedCache, }); const pendingAcknowledgments = acknowledgedMessages.filter( (message) => diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index eb7a6217c772..ed6479b24e88 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -304,6 +304,74 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.visibleTurnItems.map((row) => row.position)).toEqual([0, 1]); }); + it("moves an updated item to its new ordinal and renumbers the rows it passes", () => { + const inherited = { + ...commandItem("item-inherited", "parent", 0), + threadId: ThreadId.make("parent"), + }; + const items = [ + commandItem("item-a", "a", 1), + commandItem("item-b", "b", 2), + commandItem("item-c", "c", 2), + ]; + const rows = [ + { + position: 0, + visibility: "inherited" as const, + sourceThreadId: inherited.threadId, + sourceItemId: inherited.id, + item: inherited, + }, + ...items.map((item, index) => ({ + position: index + 1, + visibility: "local" as const, + sourceThreadId: threadId, + sourceItemId: item.id, + item, + })), + ]; + const moved = commandItem("item-a", "moved", 2); + const next = applyOrchestrationV2ProjectionEvent( + { ...emptyProjection, runs: [run], turnItems: items, visibleTurnItems: rows }, + { + id: "event-move", + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: moved, + } as OrchestrationV2DomainEvent, + ); + + // Equal ordinals order by id, so item-a stays ahead of item-b. + expect(next?.visibleTurnItems.map((row) => [row.item.id, row.position])).toEqual([ + ["item-inherited", 0], + ["item-a", 1], + ["item-b", 2], + ["item-c", 3], + ]); + expect(next?.visibleTurnItems[0]).toBe(rows[0]); + expect(next?.visibleTurnItems[1]?.item).toBe(moved); + expect(next?.turnItems.map((item) => item.id)).toEqual(["item-a", "item-b", "item-c"]); + + const later = commandItem("item-a", "later", 3); + const last = applyOrchestrationV2ProjectionEvent(next, { + id: "event-move-later", + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: later, + } as OrchestrationV2DomainEvent); + expect(last?.visibleTurnItems.map((row) => [row.item.id, row.position])).toEqual([ + ["item-inherited", 0], + ["item-b", 1], + ["item-c", 2], + ["item-a", 3], + ]); + expect(last?.turnItems.at(0)).toBe(later); + }); + describe("partial timeline", () => { const queuedRunId = RunId.make("run-queued"); const steerRunId = RunId.make("run-steer"); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index c93044e93f27..d954e8a1eda3 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -152,7 +152,10 @@ function upsertVisibleTurnItem( latestLocalTurnOrdinal: number | null | undefined, ): OrchestrationV2ThreadProjection["visibleTurnItems"] { const rows = projection.visibleTurnItems; - const index = rows.findIndex((row) => row.sourceItemId === item.id); + // Streamed updates land on the newest rows. Item ids are unique, so the + // search can start there. + let index = rows.length - 1; + while (index >= 0 && rows[index]!.sourceItemId !== item.id) index -= 1; const next = { position: 0, visibility: "local" as const, @@ -177,16 +180,34 @@ function upsertVisibleTurnItem( ) { return rows; } - const updated = index === -1 ? [...rows] : [...rows.slice(0, index), ...rows.slice(index + 1)]; - const insertionIndex = updated.findIndex( - (row) => + // The item goes before the first later local row, as if its old row were + // removed first. One pass builds and renumbers the result: this runs for + // every streamed chunk of a thread that can hold thousands of rows. + let insertion = rows.length; + for (let candidate = 0; candidate < rows.length; candidate += 1) { + const row = rows[candidate]!; + if ( + candidate !== index && row.visibility === "local" && (row.item.ordinal > item.ordinal || (row.item.ordinal === item.ordinal && - String(row.item.id).localeCompare(String(item.id)) > 0)), - ); - updated.splice(insertionIndex === -1 ? updated.length : insertionIndex, 0, next); - return renumberVisibleItems(updated); + String(row.item.id).localeCompare(String(item.id)) > 0)) + ) { + insertion = candidate; + break; + } + } + const updated: Array<(typeof rows)[number]> = []; + for (let source = 0; source < rows.length; source += 1) { + if (source === insertion) updated.push(next); + if (source !== index) updated.push(rows[source]!); + } + if (insertion === rows.length) updated.push(next); + for (let position = 0; position < updated.length; position += 1) { + const row = updated[position]!; + if (row.position !== position) updated[position] = { ...row, position }; + } + return updated; } export function applyOrchestrationV2ProjectionEvent( @@ -293,17 +314,25 @@ export function applyOrchestrationV2ProjectionEvent( case "plan.updated": return { ...base, plans: upsertEntity(base.plans, event.payload) }; case "turn-item.updated": { + // Streamed updates land on the newest items, so search from the end. + let itemIndex = projection.turnItems.length - 1; + while (itemIndex >= 0 && projection.turnItems[itemIndex]!.id !== event.payload.id) { + itemIndex -= 1; + } if ( partialTimeline && - !projection.turnItems.some((candidate) => candidate.id === event.payload.id) && + itemIndex === -1 && shouldDropMissingPartialTurnItem(projection, event.payload, latestLocalTurnOrdinal) ) { return projection; } - const next = { ...base, turnItems: upsertEntity(base.turnItems, event.payload) }; + const turnItems = [...projection.turnItems]; + if (itemIndex === -1) turnItems.push(event.payload); + else turnItems[itemIndex] = event.payload; + const next = { ...base, turnItems }; // Only interrupt requests can change another item's visibility. Streaming // text/tool updates must not recheck every row against every run. - const previous = projection.turnItems.find((item) => item.id === event.payload.id); + const previous = itemIndex === -1 ? undefined : projection.turnItems[itemIndex]; const visible = event.payload.type === "run_interrupt_request" || previous?.type === "run_interrupt_request" ? { ...next, visibleTurnItems: activeVisibleTurnItems(next) } From 3284143b454f2353ab3e0101c894986fe429123d Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 02:05:34 +0000 Subject: [PATCH 035/458] fix(server): slow terminal clients no longer queue unbounded output (#18429) Co-authored-by: SAPHID Co-authored-by: Claude Opus 5.5 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- apps/server/src/terminal/Manager.test.ts | 187 ++++++++++++ apps/server/src/terminal/Manager.ts | 210 ++++++++----- .../src/terminal/OutputProtocol.test.ts | 286 +++++++++++++++++- apps/server/src/terminal/OutputProtocol.ts | 120 +++++++- apps/server/src/ws.ts | 32 +- 5 files changed, 732 insertions(+), 103 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index f5a645349afc..d7babfbeac86 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -45,6 +45,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; +import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; import { applyTerminalAttachStreamEvent, @@ -185,6 +186,44 @@ class FakePtyAdapter { } } +// Publishes 9 MiB of output, past a stream's 8 MiB budget, while nothing pulls. +const publishPastOutputBudget = ( + manager: TerminalManager.TerminalManager["Service"], + process: FakePtyProcess, +) => + Effect.gen(function* () { + const published = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" && event.data === "last\n" + ? Deferred.succeed(published, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + for (let index = 0; index < 9; index++) process.emitData("x".repeat(1024 * 1024)); + process.emitData("last\n"); + yield* Deferred.await(published); + unsubscribe(); + }); + +// Holds the next spawn, and with it the thread lock, until `release` succeeds. +class GatedPtyAdapter extends FakePtyAdapter { + gate: { + readonly spawning: Deferred.Deferred; + readonly release: Deferred.Deferred; + } | null = null; + + override spawn( + input: PtyAdapter.PtySpawnInput, + ): Effect.Effect { + const gate = this.gate; + this.gate = null; + if (!gate) return super.spawn(input); + return Deferred.succeed(gate.spawning, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + Effect.andThen(super.spawn(input)), + ); + } +} + const waitFor = ( predicate: Effect.Effect, timeout: Duration.Input = 800, @@ -3206,6 +3245,154 @@ it.layer( }), ); + it.effect("finishes shell startup when its creating attachment is cancelled", () => + Effect.gen(function* () { + const ptyAdapter = new GatedPtyAdapter(); + const { manager } = yield* createManager(5, { ptyAdapter }); + const input = openInput(); + const gate = { + spawning: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + ptyAdapter.gate = gate; + const consumer = yield* Stream.runDrain( + terminalOutputStream( + (listener, registerCleanup) => manager.attachStream(input, listener, registerCleanup), + (listener, registerCleanup) => manager.resyncStream(input, listener, registerCleanup), + ), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(gate.spawning); + const cancelled = yield* Fiber.interrupt(consumer).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(cancelled); + + const snapshots: TerminalAttachStreamEvent[] = []; + const stop = yield* manager.attachStream(input, (event) => + Effect.sync(() => { + snapshots.push(event); + }), + ); + stop(); + expect(snapshots).toMatchObject([ + { type: "snapshot", snapshot: { status: "running", pid: ptyAdapter.processes[0]?.pid } }, + ]); + expect(ptyAdapter.processes).toHaveLength(1); + }).pipe(Effect.scoped), + ); + + it.effect("resyncs bounded history and follows close/reopen across a stalled stream", () => + Effect.gen(function* () { + const ptyAdapter = new GatedPtyAdapter(); + const historyByteLimit = 64 * 1024; + const { manager } = yield* createManager(5, { ptyAdapter, historyByteLimit }); + const input = openInput(); + const pull = yield* Stream.toPull( + terminalOutputStream( + (listener, registerCleanup) => manager.attachStream(input, listener, registerCleanup), + (listener, registerCleanup) => manager.resyncStream(input, listener, registerCleanup), + ), + ); + expect(yield* pull).toMatchObject([{ type: "snapshot", snapshot: { history: "" } }]); + const process = ptyAdapter.processes[0]!; + yield* publishPastOutputBudget(manager, process); + + const resumed = yield* pull; + expect(resumed).toHaveLength(1); + expect(resumed[0]?.type).toBe("snapshot"); + if (resumed[0]?.type !== "snapshot") throw new Error("Expected a recovery snapshot"); + expect(Buffer.byteLength(resumed[0].snapshot.history)).toBeLessThanOrEqual(historyByteLimit); + expect(resumed[0].snapshot.history.endsWith("last\n")).toBe(true); + process.emitData("after recovery\n"); + expect(yield* pull).toMatchObject([{ type: "output", data: "after recovery\n" }]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + + yield* publishPastOutputBudget(manager, process); + yield* manager.close(input); + expect(yield* pull).toEqual([ + { type: "closed", threadId: input.threadId, terminalId: input.terminalId }, + ]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + yield* manager.open(input); + expect(yield* pull).toMatchObject([ + { type: "snapshot", snapshot: { status: "running", pid: ptyAdapter.processes[1]!.pid } }, + ]); + const reopenedProcess = ptyAdapter.processes[1]!; + reopenedProcess.emitData("after reopen\n"); + expect(yield* pull).toMatchObject([{ type: "output", data: "after reopen\n" }]); + + yield* publishPastOutputBudget(manager, reopenedProcess); + // Queue close/reopen ahead of recovery while another spawn owns the thread lock. + const gate = { + spawning: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + ptyAdapter.gate = gate; + const holder = yield* manager + .open(openInput({ terminalId: "other" })) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(gate.spawning); + const closed = yield* manager.close(input).pipe(Effect.forkChild({ startImmediately: true })); + const reopened = yield* manager + .open(input) + .pipe(Effect.forkChild({ startImmediately: true })); + const recovery = yield* pull.pipe(Effect.forkChild({ startImmediately: true })); + reopenedProcess.emitData("old session\n"); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(holder); + yield* Fiber.join(closed); + yield* Fiber.join(reopened); + + const batch = yield* Fiber.join(recovery); + expect(batch).toHaveLength(1); + expect(batch[0]).toMatchObject({ + type: "snapshot", + snapshot: { status: "running", pid: ptyAdapter.processes.at(-1)!.pid }, + }); + ptyAdapter.processes.at(-1)!.emitData("new session only\n"); + expect(yield* pull).toMatchObject([{ type: "output", data: "new session only\n" }]); + expect(ptyAdapter.spawnInputs).toHaveLength(4); + }).pipe(Effect.scoped), + ); + + it.effect("does not replay in-flight output already included in an observer snapshot", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const input = openInput(); + yield* manager.open(input); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + // The snapshot sees history before this earlier fanout listener releases it. + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" && event.data === "in flight\n" + ? Deferred.succeed(reached, undefined).pipe(Effect.andThen(Deferred.await(release))) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + ptyAdapter.processes[0]!.emitData("in flight\n"); + yield* Deferred.await(reached); + const observed: TerminalAttachStreamEvent[] = []; + const stopObserving = yield* manager.observeStream(input, (event) => + Effect.gen(function* () { + observed.push(event); + if (event.type === "output" && event.data === "after\n") { + yield* Deferred.succeed(delivered, undefined); + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopObserving)); + expect(observed).toMatchObject([{ type: "snapshot", snapshot: { history: "in flight\n" } }]); + yield* Deferred.succeed(release, undefined); + ptyAdapter.processes[0]!.emitData("after\n"); + yield* Deferred.await(delivered); + expect(observed.filter((event) => event.type === "output")).toMatchObject([ + { data: "after\n" }, + ]); + }).pipe(Effect.scoped), + ); + it.effect("observes terminal history and live output without changing the process", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 406847e17175..e10d263dc33f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -163,11 +163,19 @@ export class TerminalManager extends Context.Service< readonly attachStream: ( input: TerminalAttachInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void, TerminalError>; readonly observeStream: ( input: TerminalObserveInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, + ) => Effect.Effect<() => void, TerminalError>; + + readonly resyncStream: ( + input: TerminalObserveInput, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void, TerminalError>; readonly write: (input: TerminalWriteInput) => Effect.Effect; @@ -195,6 +203,7 @@ export class TerminalManager extends Context.Service< readonly subscribe: ( listener: (event: TerminalEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => Effect.Effect<() => void>; readonly subscribeMetadata: ( @@ -2761,60 +2770,56 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); - const openOrAttachForStream = (input: TerminalAttachInput) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId; - const existing = yield* getSession(input.threadId, terminalId); + const openOrAttachForStream = Effect.fnUntraced(function* (input: TerminalAttachInput) { + const terminalId = input.terminalId; + const existing = yield* getSession(input.threadId, terminalId); - if (Option.isNone(existing)) { - if (input.resume && closedSessionKeys.has(toSessionKey(input.threadId, terminalId))) { - return null; - } - if (!input.cwd) { - return yield* new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId, - }); - } + if (Option.isNone(existing)) { + if (input.resume && closedSessionKeys.has(toSessionKey(input.threadId, terminalId))) { + return null; + } + if (!input.cwd) { + return yield* new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId, + }); + } - const resolvedInput = yield* resolveLaunchInputEnvironment({ - ...input, - terminalId, - cwd: input.cwd, - }); - return yield* openLocked(resolvedInput); - } + const resolvedInput = yield* resolveLaunchInputEnvironment({ + ...input, + terminalId, + cwd: input.cwd, + }); + return yield* Effect.uninterruptible(openLocked(resolvedInput)); + } - const session = existing.value; - const targetCols = input.cols ?? session.cols; - const targetRows = input.rows ?? session.rows; + const session = existing.value; + const targetCols = input.cols ?? session.cols; + const targetRows = input.rows ?? session.rows; - if (!input.resume && !session.process && input.cwd && input.restartIfNotRunning === true) { - const resolvedInput = yield* resolveLaunchInputEnvironment({ - ...input, - terminalId, - cwd: input.cwd, - }); - return yield* openLocked(resolvedInput); - } + if (!input.resume && !session.process && input.cwd && input.restartIfNotRunning === true) { + const resolvedInput = yield* resolveLaunchInputEnvironment({ + ...input, + terminalId, + cwd: input.cwd, + }); + return yield* Effect.uninterruptible(openLocked(resolvedInput)); + } - if ( - session.process && - session.status === "running" && - (session.cols !== targetCols || session.rows !== targetRows) - ) { - const process = session.process; - yield* resizePtyProcess(session, process, targetCols, targetRows); - session.cols = targetCols; - session.rows = targetRows; - session.updatedAt = yield* nowIso; - } + if ( + session.process && + session.status === "running" && + (session.cols !== targetCols || session.rows !== targetRows) + ) { + const process = session.process; + yield* resizePtyProcess(session, process, targetCols, targetRows); + session.cols = targetCols; + session.rows = targetRows; + session.updatedAt = yield* nowIso; + } - return snapshot(session); - }), - ); + return snapshot(session); + }); const readAllTerminalMetadata = () => readManagerState.pipe( @@ -2838,38 +2843,70 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), ); - const subscribe: TerminalManager["Service"]["subscribe"] = (listener) => + const subscribe: TerminalManager["Service"]["subscribe"] = (listener, registerCleanup) => Effect.sync(() => { terminalEventListeners.add(listener); - return () => { + const stop = () => { terminalEventListeners.delete(listener); }; + // Transfer ownership before an interrupt can discard the returned handle. + registerCleanup?.(stop); + return stop; }); + // Subscribe after taking the lock so contention cannot accumulate an unbounded replay. + // Snapshot listeners run under that lock and must not block. const streamSession = ( input: TerminalObserveInput, initial: Effect.Effect, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, ) => { let unsubscribe: (() => void) | null = null; + const stop = () => { + unsubscribe?.(); + unsubscribe = null; + }; return Effect.gen(function* () { const bufferedEvents: TerminalEvent[] = []; let deliverLive = false; + let snapshotFloor: number | null = null; - unsubscribe = yield* subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } + yield* subscribe( + (event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; - } + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } - const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; - }); + // Fanout can finish after a snapshot that already includes the event. + // Keep only its sequence, not its history, until the session changes. + if (snapshotFloor !== null) { + if ( + event.type === "started" || + event.type === "restarted" || + event.type === "error" || + event.type === "closed" + ) { + snapshotFloor = null; + } else if (typeof event.sequence === "number" && event.sequence <= snapshotFloor) { + return Effect.void; + } + } + + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }, + (cleanup) => { + unsubscribe = cleanup; + registerCleanup?.(stop); + }, + ); const initialSnapshot = yield* initial; @@ -2889,36 +2926,48 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* listener(attachEvent); } } + // The live listener keeps this closure, so release replayed output. + bufferedEvents.length = 0; + snapshotFloor = initialSnapshot?.sequence ?? null; deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; + return stop; }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), - ), + (effect) => withThreadLock(input.threadId, effect), + Effect.onError(() => Effect.sync(stop)), ); }; - const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => - streamSession(input, openOrAttachForStream(input), listener); + const attachStream: TerminalManager["Service"]["attachStream"] = ( + input, + listener, + registerCleanup, + ) => streamSession(input, openOrAttachForStream(input), listener, registerCleanup); + + const observeStream: TerminalManager["Service"]["observeStream"] = ( + input, + listener, + registerCleanup, + ) => + streamSession( + input, + requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)), + listener, + registerCleanup, + ); - const observeStream: TerminalManager["Service"]["observeStream"] = (input, listener) => + const resyncStream: TerminalManager["Service"]["resyncStream"] = ( + input, + listener, + registerCleanup, + ) => streamSession( input, - withThreadLock( - input.threadId, - requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)), + getSession(input.threadId, input.terminalId).pipe( + Effect.map((session) => (Option.isSome(session) ? snapshot(session.value) : null)), ), listener, + registerCleanup, ); const metadataEventFromTerminalEvent = ( @@ -3228,6 +3277,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func open, attachStream, observeStream, + resyncStream, write, resize, clear, diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index a12f9d6ecbf8..a05d635361b8 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -1,14 +1,20 @@ import { assert, describe, it } from "@effect/vitest"; -import { WS_METHODS } from "@t3tools/contracts"; +import { + type TerminalAttachStreamEvent, + TerminalAttachStreamEvent as TerminalAttachStreamEventSchema, + TerminalEvent as TerminalEventSchema, + WS_METHODS, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/rpc"; -import { withTerminalOutputWindow } from "./OutputProtocol.ts"; +import { terminalOutputStream, withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { it.effect.each([ @@ -77,3 +83,279 @@ describe("terminal output window", () => { }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), ); }); + +const output = (data: string): TerminalAttachStreamEvent => ({ + type: "output", + threadId: "thread-1", + terminalId: "default", + data, +}); + +const snapshot = (history: string): TerminalAttachStreamEvent => ({ + type: "snapshot", + snapshot: { + threadId: "thread-1", + terminalId: "default", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 1, + history, + exitCode: null, + exitSignal: null, + label: "bash", + updatedAt: "2026-01-01T00:00:00.000Z", + }, +}); + +// Stands in for TerminalManager's fanout: listeners run in publish order and +// may release themselves while it iterates. +const makePublisher = () => { + const listeners = new Set<(event: TerminalAttachStreamEvent) => Effect.Effect>(); + return { + listeners, + subscribe: ( + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup?: (stop: () => void) => void, + ) => + Effect.sync(() => { + listeners.add(listener); + const stop = () => { + listeners.delete(listener); + }; + registerCleanup?.(stop); + return stop; + }), + publish: (event: TerminalAttachStreamEvent) => + Effect.forEach(listeners, (listener) => listener(event), { discard: true }), + }; +}; + +// Like TerminalManager.attachStream: a snapshot, then live events. +const attach = + (publisher: ReturnType, history: string) => + ( + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + registerCleanup: (stop: () => void) => void, + ) => + publisher + .subscribe(listener, registerCleanup) + .pipe(Effect.tap(() => listener(snapshot(history)))); + +const chunkBytes = (message: RpcMessage.FromServerEncoded) => + Buffer.byteLength(JSON.stringify(message)); + +describe("terminal output stream", () => { + it.effect.each([ + WS_METHODS.terminalAttach, + WS_METHODS.terminalObserve, + WS_METHODS.subscribeTerminalEvents, + ])("releases only the ACK-stalled subscriber on %s and resumes safely", (tag) => + Effect.gen(function* () { + const legacy = tag === WS_METHODS.subscribeTerminalEvents; + const group = RpcGroup.make( + Rpc.make(tag, { + success: legacy ? TerminalEventSchema : TerminalAttachStreamEventSchema, + stream: true, + }), + ); + const publisher = makePublisher(); + const stalled = yield* Queue.unbounded(); + const healthy = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (clientId, response) => Queue.offer(clientId === 0 ? stalled : healthy, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0, 1])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + const initial = legacy ? output("ready") : snapshot(""); + yield* RpcServer.make(group).pipe( + Effect.provide( + group.toLayerHandler(tag, () => + terminalOutputStream( + (listener, registerCleanup) => + publisher + .subscribe(listener, registerCleanup) + .pipe(Effect.tap(() => listener(initial))), + legacy ? undefined : attach(publisher, "fresh history"), + 256 * 1024, + ), + ), + ), + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + const takeChunk = Effect.fnUntraced(function* (queue: typeof stalled) { + const response = yield* Queue.take(queue); + assert.equal(response._tag, "Chunk"); + if (response._tag !== "Chunk") throw new Error("Expected an RPC chunk"); + assert.isAtMost(chunkBytes(response), 70 * 1024); + return response.values; + }); + yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); + assert.deepEqual(yield* takeChunk(stalled), [initial]); + yield* write(1, { _tag: "Request", id: "2", tag, payload: null, headers: [] }); + assert.deepEqual(yield* takeChunk(healthy), [initial]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + + // Eight unacknowledged chunks fill only client 0's output window. + for (let index = 0; index < 7; index++) { + const event = output("before-stall-" + index); + yield* publisher.publish(event); + assert.deepEqual(yield* takeChunk(stalled), [event]); + assert.deepEqual(yield* takeChunk(healthy), [event]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + } + assert.equal(publisher.listeners.size, 2); + for (let index = 0; index < 100; index++) { + const event = output(index + ":" + "界".repeat(1365)); + yield* publisher.publish(event); + assert.deepEqual(yield* takeChunk(healthy), [event]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + if (index === 31) assert.equal(publisher.listeners.size, 2); + } + assert.equal(yield* Queue.size(stalled), 0); + assert.equal(publisher.listeners.size, 1); + + yield* write(0, { _tag: "Ack", requestId: "1" }); + if (legacy) { + assert.deepEqual(yield* Queue.take(stalled), { + _tag: "Exit", + requestId: "1", + exit: { _tag: "Success", value: null }, + }); + } else { + assert.deepEqual(yield* takeChunk(stalled), [snapshot("fresh history")]); + assert.equal(publisher.listeners.size, 2); + for (let index = 0; index < 7; index++) { + yield* write(0, { _tag: "Ack", requestId: "1" }); + } + } + const after = output("unique output after recovery"); + yield* publisher.publish(after); + assert.deepEqual(yield* takeChunk(healthy), [after]); + if (!legacy) assert.deepEqual(yield* takeChunk(stalled), [after]); + assert.equal(yield* Queue.size(stalled), 0); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("interrupts recovery while its subscription is still waiting", () => + Effect.gen(function* () { + const publisher = makePublisher(); + const recovering = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const pull = yield* Stream.toPull( + terminalOutputStream( + attach(publisher, ""), + () => + Deferred.succeed(recovering, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ), + 1024, + ), + ); + yield* pull; + yield* publisher.publish(output("x".repeat(1024))); + yield* publisher.publish(output("x".repeat(1024))); + assert.equal(publisher.listeners.size, 0); + + const recovery = yield* pull.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(recovering); + yield* Fiber.interrupt(recovery); + yield* Deferred.await(interrupted); + assert.equal(publisher.listeners.size, 0); + }).pipe(Effect.scoped), + ); + + it.effect("cleans listeners when cancelled during a successful acquisition handoff", () => + Effect.gen(function* () { + const publisher = makePublisher(); + for (const recovering of [false, true, false, true]) { + const handoff = yield* Deferred.make(); + const complete = yield* Deferred.make(); + const acquire: ReturnType = (listener, registerCleanup) => + attach(publisher, "")(listener, registerCleanup).pipe( + // Hold the successful exit before its cleanup handle reaches the caller. + Effect.onExit(() => + Deferred.succeed(handoff, undefined).pipe(Effect.andThen(Deferred.await(complete))), + ), + ); + const consumer = yield* Effect.gen(function* () { + const pull = yield* Stream.toPull( + terminalOutputStream(recovering ? attach(publisher, "") : acquire, acquire, 1024), + ); + yield* pull; + yield* publisher.publish(output("x".repeat(1024))); + yield* publisher.publish(output("x".repeat(1024))); + yield* pull; + }).pipe(Effect.scoped, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(handoff); + assert.equal(publisher.listeners.size, 1); + const cancellation = yield* Fiber.interrupt(consumer).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(complete, undefined); + yield* Fiber.join(cancellation); + assert.equal(publisher.listeners.size, 0); + } + }).pipe(Effect.scoped), + ); + + it.effect("caps each delivered batch at the output window in UTF-8 bytes", () => + Effect.gen(function* () { + const publisher = makePublisher(); + const pull = yield* Stream.toPull(terminalOutputStream(attach(publisher, ""))); + assert.deepEqual(yield* pull, [snapshot("")]); + // 1,024 code units but 3 KiB of UTF-8. + const data = "界".repeat(1024); + for (let index = 0; index < 1024; index++) yield* publisher.publish(output(data)); + + let delivered = 0; + while (delivered < 1024) { + const batch = yield* pull; + assert.isAtMost( + batch.reduce( + (bytes, event) => bytes + (event.type === "output" ? Buffer.byteLength(event.data) : 0), + 0, + ), + 64 * 1024, + ); + delivered += batch.length; + } + assert.equal(delivered, 1024); + }).pipe(Effect.scoped), + ); + + it.effect("charges snapshot history but never overflows on one snapshot", () => + Effect.gen(function* () { + const publisher = makePublisher(); + const history = "界".repeat(16 * 1024); + const events = terminalOutputStream(attach(publisher, history), undefined, 32 * 1024); + const pull = yield* Stream.toPull(events); + // A snapshot larger than the budget is still delivered. + assert.deepEqual(yield* pull, [snapshot(history)]); + assert.equal(publisher.listeners.size, 1); + + // Queued snapshots count their history: a second one overflows. + yield* publisher.publish(snapshot(history)); + assert.equal(publisher.listeners.size, 1); + yield* publisher.publish(snapshot(history)); + assert.equal(publisher.listeners.size, 0); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 402ed32c022a..5a0f596e8478 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -1,5 +1,9 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type TerminalAttachStreamEvent, type TerminalEvent, WS_METHODS } from "@t3tools/contracts"; +import type * as Arr from "effect/Array"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Stream from "effect/Stream"; import type { RpcServer } from "effect/rpc"; const MAX_PENDING_CHUNKS = 8; @@ -66,3 +70,117 @@ export function withTerminalOutputWindow( }), }; } + +const MAX_QUEUED_OUTPUT = 8 * 1024 * 1024; +// Charge an envelope so tiny events cannot bypass the byte budget. +const EVENT_OVERHEAD = 128; + +type Listener = (event: A) => Effect.Effect; +// Acquisition must register cleanup synchronously with installing the listener. +type Subscribe = ( + listener: Listener, + registerCleanup: (stop: () => void) => void, +) => Effect.Effect<() => void, E>; + +const queuedSize = (event: TerminalEvent | TerminalAttachStreamEvent) => + EVENT_OVERHEAD + + (event.type === "output" + ? Buffer.byteLength(event.data) + : "snapshot" in event + ? Buffer.byteLength(event.snapshot.history) + : 0); + +export function terminalOutputStream( + subscribe: Subscribe, + resync?: Subscribe, + maxQueued = MAX_QUEUED_OUTPUT, +): Stream.Stream { + return Stream.unwrap( + Effect.gen(function* () { + const ready = yield* Latch.make(); + let pending: Array = []; + let sizes: number[] = []; + let head = 0; + let queued = 0; + let overflowed = false; + let subscription = 0; + let unsubscribe: (() => void) | null = null; + const release = () => { + subscription += 1; + unsubscribe?.(); + unsubscribe = null; + pending = []; + sizes = []; + head = 0; + queued = 0; + }; + yield* Effect.addFinalizer(() => Effect.sync(release)); + + const listen = (from: Subscribe) => + Effect.suspend(() => { + const id = ++subscription; + return from( + (event) => + Effect.sync(() => { + if (id !== subscription) return; + const size = queuedSize(event); + pending.push(event); + sizes.push(size); + queued += size; + // A single snapshot must fit even when history fills the budget. + if (queued - sizes[head]! > maxQueued) { + release(); + overflowed = true; + } + ready.openUnsafe(); + }), + (stop) => { + // Overflow or cancellation may invalidate acquisition before registration. + if (id === subscription) unsubscribe = stop; + else stop(); + }, + ).pipe( + Effect.asVoid, + Effect.onError(() => Effect.sync(release)), + ); + }); + + const take = () => { + const event = pending[head]!; + pending[head] = undefined; + head += 1; + return event; + }; + const takeBatch = (): Arr.NonEmptyReadonlyArray => { + let bytes = sizes[head]!; + const batch: [A, ...A[]] = [take()]; + while (head < pending.length && bytes + sizes[head]! <= MAX_PENDING_BYTES) { + bytes += sizes[head]!; + batch.push(take()); + } + queued -= bytes; + if (head * 2 >= pending.length) { + pending = pending.slice(head); + sizes = sizes.slice(head); + head = 0; + } + return batch; + }; + + const pull: Effect.Effect, E | Cause.Done> = Effect.suspend( + () => { + if (overflowed) { + overflowed = false; + return resync ? listen(resync).pipe(Effect.andThen(pull)) : Cause.done(); + } + if (head < pending.length) return Effect.succeed(takeBatch()); + ready.closeUnsafe(); + return ready.await.pipe(Effect.andThen(pull)); + }, + ); + + yield* listen(subscribe); + return Stream.fromPull(Effect.succeed(pull)); + }), + ); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e19a47e1fe34..ae32d3626ad2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -91,9 +91,6 @@ import { type ProviderDriverKind, type ProviderInstanceId, ThreadId, - type TerminalAttachStreamEvent, - type TerminalError, - type TerminalEvent, type TerminalMetadataStreamEvent, type PullRequestRef, WS_METHODS, @@ -177,7 +174,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; -import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; +import { terminalOutputStream, withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -2824,11 +2821,11 @@ const layerWsRpc = ( [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), [WS_METHODS.terminalAttach]: (input) => - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))), + terminalOutputStream( + (listener, registerCleanup) => + terminalManager.attachStream(input, listener, registerCleanup), + (listener, registerCleanup) => + terminalManager.resyncStream(input, listener, registerCleanup), ), [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), @@ -2836,19 +2833,14 @@ const layerWsRpc = ( [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), [WS_METHODS.terminalObserve]: (input) => - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.observeStream(input, (event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))), + terminalOutputStream( + (listener, registerCleanup) => + terminalManager.observeStream(input, listener, registerCleanup), + (listener, registerCleanup) => + terminalManager.resyncStream(input, listener, registerCleanup), ), [WS_METHODS.subscribeTerminalEvents]: (_input) => - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), - ), + terminalOutputStream(terminalManager.subscribe), [WS_METHODS.subscribeTerminalMetadata]: (_input) => Stream.callback((queue) => Effect.acquireRelease( From 6327a639a6d3632f181899c425abb67acdbab6d5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:07:22 -0700 Subject: [PATCH 036/458] feat(relay): relay alerts, warnings and health dashboard via Alchemy (#22067) Co-authored-by: Claude Opus 5.5 --- .github/workflows/deploy-relay.yml | 2 + docs/operations/relay-observability.md | 36 +++ infra/relay/.env.example | 7 + infra/relay/README.md | 4 + infra/relay/src/monitoring.ts | 308 +++++++++++++++++++++++++ infra/relay/src/observability.ts | 3 + 6 files changed, 360 insertions(+) create mode 100644 infra/relay/src/monitoring.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index c8a616022592..bf8591f81da9 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -41,6 +41,8 @@ jobs: RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} RELAY_TUNNEL_CLEANUP_MODE: ${{ vars.RELAY_TUNNEL_CLEANUP_MODE }} RELAY_LEGACY_TUNNEL_CLEANUP_MODE: ${{ vars.RELAY_LEGACY_TUNNEL_CLEANUP_MODE }} + RELAY_ALERT_NOTIFIER_ID: ${{ vars.RELAY_ALERT_NOTIFIER_ID }} + RELAY_WARNING_NOTIFIER_ID: ${{ vars.RELAY_WARNING_NOTIFIER_ID }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md index 848a676acc94..6a1d7579b45c 100644 --- a/docs/operations/relay-observability.md +++ b/docs/operations/relay-observability.md @@ -108,3 +108,39 @@ Questions these answer: ) on trace_id | summarize runs = count(), failures = max(failures), backlog = max(backlog) by endpoint ``` + +## Alerts + +The stack provisions threshold monitors and a `t3-code-relay-health` dashboard from +[`infra/relay/src/monitoring.ts`](../../infra/relay/src/monitoring.ts) when the deployment sets +`RELAY_ALERT_NOTIFIER_ID`. Paging monitors go to that notifier and need two failing runs in a row. +Warnings go to `RELAY_WARNING_NOTIFIER_ID` when it is set and are otherwise only visible in Axiom. +Each monitor's description gives its healthy baseline. + +Monitors count HTTP status codes, not span errors. A request the relay cuts off at its 9 s deadline +ends as an interrupted span without an error flag, but still answers 504. `/connect` 502s are +normal: offline hosts produce most of them, retried by their clients every few seconds. + +**Links failing / tunnel recovery failing.** Open the dashboard's "Link failures by reason" table. +`provisioning: ensure-tunnel` or `configure-tunnel` with Cloudflare 429s on the "Cloudflare API calls +by status" chart is the #16810 failure: the account's Cloudflare API rate limit. Ask Cloudflare to +raise it, and check whether something is calling the API in a loop (tunnel recovery from hosts is the +largest caller). Slow Postgres instead points at the database or Hyperdrive. + +**Environments unreachable.** Clients reach fewer hosts than usual. Check Cloudflare Tunnel status and +recent relay deploys before suspecting hosts: a host-side problem does not move this many +environments at once. + +**Push delivery failing.** Look at the `relay.apns_deliveries.process_signed_job` or `relay.fcm.process` +error messages. An expired APNs key or FCM service account fails every job. + +**Cloudflare API rate limited / near its limit.** The warning fires on sustained 429s; the page fires +when they are frequent enough to stall provisioning. Compare the call volume on the dashboard with the +account limit. + +**Traffic dropped.** The Worker is down, a deploy broke it, or spans stopped reaching Axiom. Check +`https://relay.t3.codes/health` first. + +**Links degraded / database slow.** Warnings for slow degradation. Link failures above 20% over six +hours have so far meant provisioning running into the 9 s deadline, with Postgres latency as the +usual cause. diff --git a/infra/relay/.env.example b/infra/relay/.env.example index 3e7b6b1d109d..87a9a1ca32cb 100644 --- a/infra/relay/.env.example +++ b/infra/relay/.env.example @@ -15,6 +15,13 @@ RELAY_TUNNEL_ZONE_NAME=tunnels.example.com # Ignored on prod. # RELAY_LEGACY_TUNNEL_GRACE_MINUTES=10 +# Optional: relay monitors and health dashboard in Axiom. Set the notifier id +# that pages (incident.io in prod) to create them; unset creates neither, so +# personal stages never page anyone. Warnings go to their own notifier, or +# only show in Axiom when it is unset. +# RELAY_ALERT_NOTIFIER_ID= +# RELAY_WARNING_NOTIFIER_ID= + # Optional: Relay domain override # Set this only when the derived relay hostname should not be used. # RELAY_DOMAIN=relay.example.com diff --git a/infra/relay/README.md b/infra/relay/README.md index 350140c9b104..edcd73415477 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -141,6 +141,10 @@ The `production` GitHub environment must define these Actions variables: - `RELAY_API_ZONE_NAME` - `RELAY_TUNNEL_ZONE_NAME` - `RELAY_DOMAIN` if overriding the derived production relay domain +- `RELAY_ALERT_NOTIFIER_ID`, the Axiom notifier that relay alerts page. Without it the deployment + creates no monitors or health dashboard. See + [Relay alerts](../../docs/operations/relay-observability.md#alerts). +- `RELAY_WARNING_NOTIFIER_ID`, optional, the Axiom notifier for relay warnings - `CLERK_PUBLISHABLE_KEY` - `CLERK_JWT_AUDIENCE` - `CLERK_JWT_TEMPLATE` diff --git a/infra/relay/src/monitoring.ts b/infra/relay/src/monitoring.ts new file mode 100644 index 000000000000..154b2c15cc3f --- /dev/null +++ b/infra/relay/src/monitoring.ts @@ -0,0 +1,308 @@ +import * as Alchemy from "alchemy"; +import * as Axiom from "alchemy/Axiom"; +import * as Output from "alchemy/Output"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { relayResourceNameForStage } from "./deploymentConfig.ts"; + +const RUNBOOK_URL = + "https://github.com/pingdotgg/t3code/blob/main/docs/operations/relay-observability.md#alerts"; + +const LINK_ROUTE = `"/v1/client/environment-links"`; +const TUNNEL_ROUTES = `"/v1/environments/:environmentId/tunnel", "/v1/environments/:environmentId/tunnel/recovery"`; +const CLOUDFLARE_API = `['attributes.url.full'] startswith "https://api.cloudflare.com"`; +const STATUS = `toint(['attributes.http.response.status_code'])`; + +// Status codes, not span errors: a request cut off by the relay deadline ends +// as an interrupted span with no error flag, but still answers 504. +// Below `minimum` requests the rate reads 0, so a quiet window cannot page. +const serverFailurePercent = (dataset: string, routes: string, minimum: number) => + [ + `['${dataset}']`, + `| where kind == "server" and ['attributes.http.request.method'] == "POST" and ['attributes.http.route'] in (${routes})`, + `| summarize total = count(), failed = countif(${STATUS} >= 500)`, + `| project failure_pct = iff(total >= ${minimum}, 100.0 * failed / total, 0.0)`, + ].join("\n"); + +type Severity = "page" | "warn"; + +interface RelayMonitor { + readonly id: string; + readonly severity: Severity; + readonly name: string; + /** Why it fires and the healthy baseline. The runbook link is appended. */ + readonly description: string; + readonly query: (dataset: string) => string; + readonly operator: "Above" | "AboveOrEqual" | "Below"; + readonly threshold: number; + readonly rangeMinutes: number; + readonly intervalMinutes?: number; + readonly triggerFromNRuns?: number; + readonly alertOnNoData?: boolean; +} + +// Thresholds come from 2026-10-16..19 prod data: the worst healthy window, +// and the #16810 outage (Cloudflare API 429s stalling every provision). +// Paging monitors need two bad runs in a row; warnings catch the slow +// degradations an outage threshold has to ignore. +const relayMonitors: ReadonlyArray = [ + { + id: "RelayLinkFailureRateMonitor", + severity: "page", + name: "T3 Connect relay: links failing", + description: + "Share of POST /v1/client/environment-links (turning on T3 Connect) answering 5xx over 15 min. Healthy: median 11%, worst 75%. #16810: 87-96%.", + query: (dataset) => serverFailurePercent(dataset, LINK_ROUTE, 20), + operator: "Above", + threshold: 80, + rangeMinutes: 15, + triggerFromNRuns: 2, + }, + { + id: "RelayTunnelFailureRateMonitor", + severity: "page", + name: "T3 Connect relay: tunnel recovery failing", + description: + "Share of host tunnel registration and recovery answering 5xx over 15 min. Hosts whose tunnel drops cannot come back. Healthy: median 4%, worst 52%. #16810: 83-94%.", + query: (dataset) => serverFailurePercent(dataset, TUNNEL_ROUTES, 200), + operator: "Above", + threshold: 60, + rangeMinutes: 15, + triggerFromNRuns: 2, + }, + { + id: "RelayConnectReachabilityMonitor", + severity: "page", + name: "T3 Connect relay: environments unreachable", + description: + "Share of environments clients tried to reach that connected at least once in 30 min. Offline hosts keep this below 100%, and retry storms against them do not move it. Healthy: 65-72%.", + query: (dataset) => + [ + `['${dataset}']`, + `| where name == "relay.environment_connector.connect"`, + `| extend environment = tostring(['attributes.custom']['relay.environment_id'])`, + `| summarize environments = dcount(environment), connected = dcountif(environment, error != true)`, + `| project reachable_pct = iff(environments >= 1000, 100.0 * connected / environments, 100.0)`, + ].join("\n"), + operator: "Below", + threshold: 55, + rangeMinutes: 30, + triggerFromNRuns: 2, + }, + { + id: "RelayPushFailureRateMonitor", + severity: "page", + name: "T3 Connect relay: push delivery failing", + description: + "Share of APNs and FCM delivery jobs failing over 15 min, e.g. an expired APNs key or FCM credential. Healthy: APNs under 0.3%, FCM under 4%.", + query: (dataset) => + [ + `['${dataset}']`, + `| where name in ("relay.apns_deliveries.process_signed_job", "relay.fcm.process")`, + `| summarize total = count(), failed = countif(error == true)`, + `| project failure_pct = iff(total >= 100, 100.0 * failed / total, 0.0)`, + ].join("\n"), + operator: "Above", + threshold: 10, + rangeMinutes: 15, + triggerFromNRuns: 2, + }, + { + id: "RelayCloudflareRateLimitedMonitor", + severity: "page", + name: "T3 Connect relay: Cloudflare API rate limited", + description: + "Cloudflare API 429s per 10 min from tunnel and DNS provisioning. Sustained at this level, links and recoveries stall. Peak before #16810: 1,286; during it: ~11k.", + query: (dataset) => + `['${dataset}']\n| where ${CLOUDFLARE_API} and ${STATUS} == 429\n| summarize rate_limited = count()`, + operator: "AboveOrEqual", + threshold: 500, + rangeMinutes: 10, + triggerFromNRuns: 2, + }, + { + id: "RelayTrafficDroppedMonitor", + severity: "page", + name: "T3 Connect relay: traffic dropped", + description: + "Relay requests per 10 min collapsed: Worker down, broken deploy, or trace export stopped. Healthy minimum: ~69k.", + query: (dataset) => `['${dataset}']\n| where kind == "server"\n| summarize requests = count()`, + operator: "Below", + threshold: 10_000, + rangeMinutes: 10, + triggerFromNRuns: 2, + alertOnNoData: true, + }, + { + id: "RelayCloudflareRateLimitWarningMonitor", + severity: "warn", + name: "T3 Connect relay: Cloudflare API near its limit", + description: + "Any sustained Cloudflare API 429s. These bursts ran for days before #16810; raise the account limit or cut call volume before they grow.", + query: (dataset) => + `['${dataset}']\n| where ${CLOUDFLARE_API} and ${STATUS} == 429\n| summarize rate_limited = count()`, + operator: "AboveOrEqual", + threshold: 20, + rangeMinutes: 10, + triggerFromNRuns: 2, + }, + { + id: "RelayLinkChronicFailureMonitor", + severity: "warn", + name: "T3 Connect relay: links degraded", + description: + "Share of links answering 5xx over 6 h, mostly 9 s deadline 504s from slow provisioning. Healthy days: 10-18%. Degraded days before #16810: 28-33%.", + query: (dataset) => serverFailurePercent(dataset, LINK_ROUTE, 100), + operator: "Above", + threshold: 20, + rangeMinutes: 360, + intervalMinutes: 30, + }, + { + id: "RelayDatabaseLatencyMonitor", + severity: "warn", + name: "T3 Connect relay: database slow", + description: + "p90 Postgres query time over 15 min, in ms. Every relay request runs several queries in sequence, so this sets how close provisioning gets to the 9 s deadline. Healthy: ~200 ms.", + query: (dataset) => + `['${dataset}']\n| where name == "postgres"\n| summarize p90 = percentile(duration, 90)\n| project p90_ms = p90 / 1000000`, + operator: "Above", + threshold: 500, + rangeMinutes: 15, + triggerFromNRuns: 2, + }, +]; + +interface QueryChart { + readonly id: string; + readonly name: string; + readonly type: "TimeSeries" | "Table"; + readonly query: { readonly apl: string }; +} + +const timeSeries = (id: string, name: string, apl: string): QueryChart => ({ + id, + name, + type: "TimeSeries", + query: { apl }, +}); + +const relayDashboardCharts = (dataset: string): ReadonlyArray => { + const source = `['${dataset}']`; + const linkRequests = `${source}\n| where kind == "server" and ['attributes.http.request.method'] == "POST" and ['attributes.http.route'] == ${LINK_ROUTE}`; + const tunnelRequests = `${source}\n| where kind == "server" and ['attributes.http.request.method'] == "POST" and ['attributes.http.route'] in (${TUNNEL_ROUTES})`; + return [ + timeSeries( + "links", + "Links by outcome", + `${linkRequests}\n| summarize succeeded = countif(${STATUS} < 400), rejected = countif(${STATUS} >= 400 and ${STATUS} < 500), failed = countif(${STATUS} >= 500) by bin_auto(_time)`, + ), + timeSeries( + "link-latency", + "Link latency (ms, 9 s deadline)", + `${linkRequests}\n| summarize p50 = percentile(duration, 50) / 1000000, p90 = percentile(duration, 90) / 1000000 by bin_auto(_time)`, + ), + timeSeries( + "reachability", + "Environments reachable per 30 min (%)", + `${source}\n| where name == "relay.environment_connector.connect"\n| extend environment = tostring(['attributes.custom']['relay.environment_id'])\n| summarize environments = dcount(environment), connected = dcountif(environment, error != true) by bin(_time, 30m)\n| project _time, reachable_pct = 100.0 * connected / environments`, + ), + timeSeries( + "tunnel", + "Tunnel registration and recovery by outcome", + `${tunnelRequests}\n| summarize succeeded = countif(${STATUS} < 400), rejected = countif(${STATUS} >= 400 and ${STATUS} < 500), failed = countif(${STATUS} >= 500) by bin_auto(_time)`, + ), + timeSeries( + "cloudflare-api", + "Cloudflare API calls by status", + `${source}\n| where ${CLOUDFLARE_API}\n| summarize count() by bin_auto(_time), status = ${STATUS}`, + ), + timeSeries( + "postgres", + "Postgres query time (ms)", + `${source}\n| where name == "postgres"\n| summarize p50 = percentile(duration, 50) / 1000000, p90 = percentile(duration, 90) / 1000000 by bin_auto(_time)`, + ), + timeSeries( + "push", + "Push delivery jobs", + `${source}\n| where name in ("relay.apns_deliveries.process_signed_job", "relay.fcm.process")\n| summarize delivered = countif(error != true), failed = countif(error == true) by bin_auto(_time), service = iff(name == "relay.fcm.process", "fcm", "apns")`, + ), + timeSeries( + "server-errors", + "5xx by route", + `${source}\n| where kind == "server" and ${STATUS} >= 500\n| summarize count() by bin_auto(_time), route = tostring(['attributes.http.route']), status = ${STATUS}`, + ), + { + id: "link-failures", + name: "Link failures by reason", + type: "Table", + query: { + apl: `${source}\n| where name == "relay.environment_linker.link" and error == true\n| extend message = tostring(['status.message'])\n| summarize failures = count() by reason = case(message contains "failed during '", strcat("provisioning: ", extract("failed during '([^']+)'", 1, message)), message startswith "Managed tunnel limit reached", "managed tunnel limit reached", substring(message, 0, 80))\n| top 10 by failures`, + }, + }, + ]; +}; + +const notifierConfig = (name: string) => + Config.String(name).pipe( + Config.option, + Config.map(Option.flatMap((value) => Option.liftPredicate(value.trim(), Boolean))), + ); + +/** + * A deployment opts in by naming the notifier that pages (incident.io for + * prod). Personal stages and forks leave it unset and get no monitors or + * dashboard. Warnings go to their own notifier when one is set, and are only + * visible in Axiom otherwise. + */ +export const RelayMonitoring = Effect.fn("RelayMonitoring")(function* (traces: Axiom.Dataset) { + const { stage } = yield* Alchemy.Stack; + const page = yield* notifierConfig("RELAY_ALERT_NOTIFIER_ID"); + if (Option.isNone(page)) return; + const notifiers: Record> = { + page, + warn: yield* notifierConfig("RELAY_WARNING_NOTIFIER_ID"), + }; + + for (const monitor of relayMonitors) { + yield* Axiom.Monitor(monitor.id, { + name: stage === "prod" ? monitor.name : `[${stage}] ${monitor.name}`, + description: `${monitor.description} Runbook: ${RUNBOOK_URL}`, + type: "Threshold", + aplQuery: Output.map(traces.name, monitor.query), + operator: monitor.operator, + threshold: monitor.threshold, + intervalMinutes: monitor.intervalMinutes ?? 5, + rangeMinutes: monitor.rangeMinutes, + triggerFromNRuns: monitor.triggerFromNRuns ?? 1, + alertOnNoData: monitor.alertOnNoData ?? false, + notifierIds: Option.toArray(notifiers[monitor.severity]), + }); + } + + yield* Axiom.Dashboard("RelayHealthDashboard", { + dashboard: Output.map(traces.name, (dataset) => { + const charts = relayDashboardCharts(dataset); + return { + name: relayResourceNameForStage("t3-code-relay-health", stage), + owner: "", + description: `T3 Connect relay health. Alerts and runbook: ${RUNBOOK_URL}`, + refreshTime: 60, + schemaVersion: 2, + timeWindowStart: "qr-now-24h", + timeWindowEnd: "qr-now", + charts: [...charts], + // Two charts per row on Axiom's 12-column grid. + layout: charts.map((chart, index) => ({ + i: chart.id, + x: (index % 2) * 6, + y: Math.floor(index / 2) * 6, + w: 6, + h: 6, + })), + }; + }), + }); +}); diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index db3eb17c4187..ad43573802b0 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; import { relayResourceNameForStage } from "./deploymentConfig.ts"; +import { RelayMonitoring } from "./monitoring.ts"; const relayRecentSpansQuery = (dataset: string) => [ @@ -62,6 +63,8 @@ export const RelayObservability = Effect.gen(function* () { aplQuery: Output.map(traces.name, relayRecentSpansQuery), }); + yield* RelayMonitoring(traces); + return { traces, workerIngestToken, mobileIngestToken, clientIngestToken } as const; }); From 99ed6af92a1a410fe33e4049b1563ff262de83a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:12:03 -0700 Subject: [PATCH 037/458] fix(relay): share the relay health dashboard with the whole Axiom org (#22074) Co-authored-by: Claude Opus 5.5 --- infra/relay/src/monitoring.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infra/relay/src/monitoring.ts b/infra/relay/src/monitoring.ts index 154b2c15cc3f..5bdc99af1ad6 100644 --- a/infra/relay/src/monitoring.ts +++ b/infra/relay/src/monitoring.ts @@ -287,7 +287,9 @@ export const RelayMonitoring = Effect.fn("RelayMonitoring")(function* (traces: A const charts = relayDashboardCharts(dataset); return { name: relayResourceNameForStage("t3-code-relay-health", stage), - owner: "", + // Shared with the whole org. An empty owner only means that for API + // tokens; deploys with a personal token would make it private. + owner: "X-AXIOM-EVERYONE", description: `T3 Connect relay health. Alerts and runbook: ${RUNBOOK_URL}`, refreshTime: 60, schemaVersion: 2, From 293938b91a6aacf8622cd91001ea324856a240da Mon Sep 17 00:00:00 2001 From: Evolve Performance Date: Tue, 20 Oct 2026 04:13:48 +0200 Subject: [PATCH 038/458] fix(server): an interrupted run no longer starts the settle timer on a snoozed thread (#17789) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../orchestration-v2/ThreadSettlementService.test.ts | 9 ++++++++- .../src/orchestration-v2/ThreadSettlementService.ts | 12 ++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 4ad315427cd8..b22ff6a311e2 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -177,10 +177,17 @@ describe("isAutoSettlementCandidate", () => { ).toBe(true); expect( ThreadSettlementService.isAutoSettlementCandidate( - shell({ ...snoozed, latestRunCompletedAt: at(-30 * 60 * 1_000) }), + shell({ ...snoozed, status: "completed", latestRunCompletedAt: at(-30 * 60 * 1_000) }), NOW_MS, ), ).toBe(true); + // An interrupted run leaves the thread snoozed, as the sidebar shows it. + expect( + ThreadSettlementService.isAutoSettlementCandidate( + shell({ ...snoozed, status: "interrupted", latestRunCompletedAt: at(-30 * 60 * 1_000) }), + NOW_MS, + ), + ).toBe(false); expect( ThreadSettlementService.isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-2 * 60 * 60 * 1_000) }), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index d6cfe2caec4d..64a54e92a597 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -166,16 +166,8 @@ export function isAutoSettlementCandidate( if (thread.activityRunStatus != null) return false; if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; - const snoozedUntilMs = toMillis(thread.snoozedUntil); - if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; - const snoozedAtMs = toMillis(thread.snoozedAt); - const completedAtMs = toMillis(thread.latestRunCompletedAt); - const wokeOnError = - thread.status === "failed" && - (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs)); - const wokeOnCompletion = - snoozedAtMs !== null && completedAtMs !== null && completedAtMs > snoozedAtMs; - return wokeOnError || wokeOnCompletion; + // Interrupted runs must keep the same snooze protection the sidebar shows. + return !isSnoozed(thread, nowMs); } /** From 25f8fcb18c02b2a7d6be754f155073b6b61840d3 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:50:25 +0530 Subject: [PATCH 039/458] fix(server): refresh provider discovery after CLI updates (#14319) Co-authored-by: Julius Marminge --- .../src/provider/ProviderRegistry.test.ts | 273 +++++++++++++++++- apps/server/src/provider/ProviderRegistry.ts | 73 +++++ .../provider/providerCompatibility.test.ts | 2 + .../providerMaintenanceRunner.test.ts | 86 ++---- .../src/provider/providerMaintenanceRunner.ts | 26 +- .../testUtils/providerRegistryMock.ts | 1 + docs/user/updating.md | 9 + 7 files changed, 383 insertions(+), 87 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index ba0833018e2f..6733f8575e8e 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -45,6 +45,8 @@ import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ModelManifest from "./ModelManifest.ts"; +import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; @@ -68,7 +70,10 @@ import type { import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import { ProviderDriverError } from "./Errors.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, +} from "@t3tools/provider-core/server/maintenanceResolver"; const decodeServerProvider = Schema.decodeSync(ServerProvider); const encodeServerProvider = Schema.encodeSync(ServerProvider); const decodeServerSettings = Schema.decodeSync(ServerSettings); @@ -399,6 +404,7 @@ const awaitPersistedProvider = ( ); const layerTestNodeServices = Layer.mergeAll( + ProviderLatestVersions.layer, NodeServices.layer, Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "unused-managed-installation", @@ -1947,6 +1953,271 @@ it.layer( }), ); + const updateScenarios = [ + "healthy", + "unauthenticated", + "sibling defects", + "selected defect", + "selected disabled", + ] as const; + it.effect.each(updateScenarios)("refreshes account caches after update (%s)", (scenario) => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("example"); + const packageName = "@example/provider"; + const manifestRefreshes = yield* Ref.make(0); + const events: string[] = []; + const manifest = { + version: 1, + currentModels: {}, + compatibility: [{ driver, t3CodeRange: ">=0.0.42", ranges: [] }], + } satisfies ModelManifest.ModelManifestData; + const makeInstance = ( + id: string, + config: { binaryPath: string; home: string; model: string }, + enabled = true, + driverKind = driver, + ): ProviderInstance => { + const instanceId = ProviderInstanceId.make(id); + const provider = { + instanceId, + driver: driverKind, + status: scenario === "unauthenticated" ? "error" : "ready", + enabled, + installed: true, + auth: { + status: + scenario === "unauthenticated" || scenario === "selected defect" + ? "unauthenticated" + : "authenticated", + label: config.home, + }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: scenario === "selected defect" && id === "personal" ? "2.0.0" : "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + let cached = true; + return { + instanceId, + driverKind, + continuationIdentity: { driverKind, continuationKey: config.home }, + displayName: undefined, + enabled, + snapshot: { + resolveMaintenance: () => + Effect.sync(() => { + if (id === "broken-maintenance" && events.includes("update")) { + throw new Error("Sibling maintenance failed"); + } + return makeProviderMaintenanceCapabilities({ + provider: driverKind, + packageName: scenario === "selected defect" ? null : packageName, + updateExecutable: config.binaryPath, + updateArgs: ["update"], + env: { PROVIDER_HOME: config.home }, + updateLockKey: config.binaryPath, + latestVersion: scenario === "selected defect" ? null : "2.0.0", + }); + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.sync(() => { + events.push(`probe:${id}`); + if ( + id === "broken-discovery" || + (scenario === "selected defect" && id === "personal") + ) { + throw new Error("Provider discovery failed"); + } + return cached + ? provider + : { + ...provider, + version: "2.0.0", + models: [ + { + slug: config.model, + name: config.model, + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + }; + }), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + invalidateCaches: Effect.sync(() => { + cached = false; + events.push(`invalidate:${id}`); + }), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + }; + const instances = [ + makeInstance( + "personal", + { binaryPath: "/personal/agent", home: "/personal", model: "personal-model" }, + scenario !== "selected disabled", + ), + makeInstance("work", { binaryPath: "/work/agent", home: "/work", model: "work-model" }), + ...(scenario === "sibling defects" + ? ["broken-maintenance", "broken-discovery"].map((id) => + makeInstance(id, { binaryPath: `/${id}/agent`, home: `/${id}`, model: id }), + ) + : []), + makeInstance( + "disabled", + { binaryPath: "/disabled/agent", home: "/disabled", model: "disabled-model" }, + false, + ), + makeInstance( + "unrelated", + { binaryPath: "/other/agent", home: "/other", model: "other-model" }, + true, + ProviderDriverKind.make("other"), + ), + ]; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderMaintenanceRunner.layer.pipe( + Layer.provideMerge(ProviderRegistry.layer), + Layer.provideMerge( + Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }), + ), + Layer.provideMerge( + Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(manifest), + refresh: Effect.succeed(manifest), + forceRefresh: Ref.update(manifestRefreshes, (count) => count + 1).pipe( + Effect.as(manifest), + ), + refreshInBackground: Effect.void, + }), + ), + Layer.provideMerge( + Layer.effect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([ + [packageName, "1.0.0"], + ["unrelated-package", "1.0.0"], + ]), + ), + ), + Layer.provideMerge(Layer.succeed(HostProcess.Platform, "linux")), + Layer.provideMerge( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + assert.strictEqual(command._tag, "StandardCommand"); + if (command._tag !== "StandardCommand") throw new Error("Unexpected pipeline"); + assert.strictEqual(command.command, "/personal/agent"); + assert.deepStrictEqual(command.args, ["update"]); + assert.strictEqual(command.options.env?.PROVIDER_HOME, "/personal"); + events.push("update"); + return mockHandle({ stdout: "updated", stderr: "", code: 0 }); + }), + ), + ), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-update-caches-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const runner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const registry = yield* ProviderRegistry.ProviderRegistry; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const before = yield* registry.getProviders; + if (scenario === "selected defect") { + const selected = before.find((p) => p.instanceId === "personal"); + assert.strictEqual(selected?.version, "2.0.0"); + assert.strictEqual(selected?.status, "ready"); + assert.strictEqual(selected?.auth.status, "unauthenticated"); + assert.strictEqual(selected?.versionAdvisory, undefined); + } + const result = yield* runner.updateProvider({ + provider: driver, + instanceId: ProviderInstanceId.make("personal"), + }); + assert.strictEqual( + result.providers.find((p) => p.instanceId === "personal")?.updateState?.status, + scenario === "selected defect" ? "unchanged" : "succeeded", + ); + assert.deepStrictEqual( + events.filter((event) => event === "update"), + ["update"], + ); + assert.strictEqual(events[0], "update"); + assert.deepStrictEqual( + events.filter((event) => event.startsWith("probe:")).toSorted(), + [ + "probe:personal", + "probe:work", + ...(scenario === "sibling defects" ? ["probe:broken-discovery"] : []), + ].toSorted(), + ); + for (const id of ["personal", "work"]) { + assert.isBelow(events.indexOf(`invalidate:${id}`), events.indexOf(`probe:${id}`)); + assert.include(events, `invalidate:${id}`); + const provider = result.providers.find((p) => p.instanceId === id); + assert.deepStrictEqual( + provider?.models.map((model) => model.slug), + scenario === "selected defect" && id === "personal" ? [] : [`${id}-model`], + ); + assert.strictEqual(provider?.auth.label, `/${id}`); + if (scenario === "unauthenticated") { + assert.strictEqual(provider?.status, "error"); + assert.strictEqual(provider?.auth.status, "unauthenticated"); + assert.strictEqual(provider?.version, "2.0.0"); + } + } + for (const id of scenario === "sibling defects" + ? ["broken-maintenance", "broken-discovery"] + : scenario === "selected defect" + ? ["personal"] + : []) { + assert.strictEqual(result.providers.find((p) => p.instanceId === id)?.status, "error"); + } + assert.isAtLeast(yield* Ref.get(manifestRefreshes), 1); + assert.strictEqual( + yield* latestVersions.cached(packageName, Effect.succeed("2.0.0")), + scenario === "selected defect" ? "1.0.0" : "2.0.0", + ); + assert.strictEqual( + yield* latestVersions.cached("unrelated-package", Effect.succeed("2.0.0")), + "1.0.0", + ); + assert.strictEqual( + result.providers.find((p) => p.instanceId === "work")?.updateState, + undefined, + ); + for (const id of ["disabled", "unrelated"]) { + assert.notInclude(events, `invalidate:${id}`); + assert.deepStrictEqual( + result.providers.find((p) => p.instanceId === id), + before.find((p) => p.instanceId === id), + ); + } + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index c459f670fcaf..b45a2d73d0b5 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -21,6 +21,7 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ServerConfig from "../config.ts"; @@ -63,6 +64,12 @@ export class ProviderRegistry extends Context.Service< instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** Only refreshed instances can verify an update; the published list still includes every row. */ + readonly refreshAfterUpdate: ( + provider: ProviderDriverKind, + selectedInstanceId: ProviderInstanceId, + ) => Effect.Effect>; + readonly refreshWorkspaceSnapshot: (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; @@ -359,6 +366,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const manifestService = yield* ModelManifest.ModelManifest; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const serviceScope = yield* Effect.scope; const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -688,6 +696,70 @@ export const layer = Layer.effect( return yield* refreshOneSource(providerSource); }); + const refreshAfterUpdate = Effect.fn("refreshAfterUpdate")(function* ( + provider: ProviderDriverKind, + selectedInstanceId: ProviderInstanceId, + ) { + yield* manifestService.forceRefresh; + const instances = (yield* instanceRegistry.listInstances).filter( + (instance) => + instance.driverKind === provider && + (instance.enabled || instance.instanceId === selectedInstanceId), + ); + const failedInstances = new Set(); + const recoverInstanceFailure = Effect.fn("recoverPostUpdateRefreshFailure")(function* ( + instanceId: ProviderInstanceId, + cause: Cause.Cause, + ) { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.interrupt; + } + yield* Effect.logError("provider post-update refresh failed", { + instanceId, + cause: Cause.pretty(cause), + }); + failedInstances.add(instanceId); + // A cached ready snapshot cannot verify a successful update. + return yield* updateProviders((providers) => + providers.map((candidate) => + candidate.instanceId === instanceId + ? { ...candidate, status: "error", message: "Provider refresh failed after update." } + : candidate, + ), + ); + }); + // Accounts can share an installation, but discovery must use each + // instance's own executable, environment, and credentials. + yield* Effect.forEach( + instances, + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + if (maintenance.packageName) { + yield* latestVersions.invalidate(maintenance.packageName); + } + }).pipe(Effect.catchCause((cause) => recoverInstanceFailure(instance.instanceId, cause))), + { concurrency: "unbounded", discard: true }, + ); + yield* Effect.forEach( + instances.filter((instance) => !failedInstances.has(instance.instanceId)), + (instance) => + refreshOneSource(buildSnapshotSource(instance)).pipe( + Effect.catchCause((cause) => recoverInstanceFailure(instance.instanceId, cause)), + ), + { concurrency: "unbounded", discard: true }, + ); + const refreshedInstanceIds = new Set( + instances + .filter((instance) => !failedInstances.has(instance.instanceId)) + .map((instance) => instance.instanceId), + ); + return (yield* Ref.get(providersRef)).filter((candidate) => + refreshedInstanceIds.has(candidate.instanceId), + ); + }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( "getProviderMaintenanceCapabilitiesForInstance", )(function* ( @@ -963,6 +1035,7 @@ export const layer = Layer.effect( refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), refreshWorkspaceSnapshot: (input) => refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshAfterUpdate, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 9f185f933135..c03a79fa19f8 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -11,6 +11,7 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "../config.ts"; import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; @@ -405,6 +406,7 @@ it.effect("a remote policy refresh preserves a newer health result on the regist ), ); const layerDependencies = Layer.mergeAll( + ProviderLatestVersions.layer, Layer.succeed(ModelManifest.ModelManifest, { current: Ref.get(manifest), refresh, diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index a35ea8213289..5c5bf45629e9 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -198,6 +198,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshAfterUpdate: () => Ref.get(providersRef), refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), @@ -288,7 +289,7 @@ describe("providerMaintenanceRunner", () => { const { registry, providersRef } = yield* makeRegistry(baseProvider); const updater = yield* makeTestRunner({ ...registry, - refreshInstance: () => + refreshAfterUpdate: () => Ref.updateAndGet(providersRef, (providers) => providers.map((provider) => ({ ...provider, installed: false, version: null })), ), @@ -315,7 +316,7 @@ describe("providerMaintenanceRunner", () => { const { registry, providersRef } = yield* makeRegistry(baseNativeCliProvider); const updater = yield* makeTestRunner({ ...registry, - refreshInstance: () => + refreshAfterUpdate: () => Ref.updateAndGet(providersRef, (providers) => providers.map((provider) => ({ ...provider, installed: true, version: null })), ), @@ -626,77 +627,34 @@ describe("providerMaintenanceRunner", () => { }, ); - it.effect("updates a single provider instance without touching sibling instances", () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const personalInstanceId = ProviderInstanceId.make("codex_personal"); - const workInstanceId = ProviderInstanceId.make("codex_work"); - const refreshedInstanceIds: Array = []; - const { registry } = yield* makeRegistry([ - { - ...baseProvider, - instanceId: personalInstanceId, - version: "0.124.0-alpha.3", - }, - { - ...baseProvider, - instanceId: workInstanceId, - version: "0.124.0-alpha.3", - }, - ]); + it.effect("does not report verified success when selected-instance discovery fails", () => + Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(baseNativeCliProvider); const updater = yield* makeTestRunner({ ...registry, - getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: "@openai/codex-instance-test", - updateExecutable: "vp", - updateArgs: ["i", "-g", "@openai/codex"], - updateLockKey: "vite-plus-global", - }), - ).pipe( - Effect.tap(() => Effect.sync(() => assert.strictEqual(instanceId, personalInstanceId))), - ), - refreshInstance: (instanceId) => - registry.refreshInstance(instanceId).pipe( - Effect.tap(() => - Effect.sync(() => { - refreshedInstanceIds.push(instanceId); - }), - ), + refreshAfterUpdate: () => + Ref.updateAndGet(providersRef, (providers) => + providers.map((provider) => ({ + ...provider, + status: "error", + message: "Discovery failed.", + })), ), }); - - const result = yield* updater.updateProvider({ - provider: CODEX_DRIVER, - instanceId: personalInstanceId, - }); - - assert.deepStrictEqual(calls, [ - { - command: "vp", - args: ["i", "-g", "@openai/codex"], - }, - ]); - assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]); - assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - assert.strictEqual(result.providers[1]?.instanceId, workInstanceId); - assert.strictEqual(result.providers[1]?.updateState, undefined); + const result = yield* updater.updateProvider(NATIVE_CLI_DRIVER); + assert.strictEqual(result.providers[0]?.status, "error"); + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.match(result.providers[0]?.updateState?.message ?? "", /could not verify/); }).pipe( Effect.provide( Layer.mergeAll( layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.124.0-alpha.3"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner(() => ({ stdout: "updated" })), ), ), - ); - }); + ), + ); it.effect("records command failure output in provider update state", () => Effect.gen(function* () { @@ -1066,7 +1024,7 @@ it.effect.each([ { ...registry, getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), - refreshInstance: () => + refreshAfterUpdate: () => Effect.gen(function* () { assert.strictEqual(calls.length, 1); const refreshed = yield* enrichProviderSnapshotWithVersionAdvisory( diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index fc5bc85dea64..df6233f0e547 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -306,28 +306,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { maintenanceCapabilities: ProviderMaintenanceCapabilities, instanceId: ProviderInstanceId, ): Effect.Effect => - providerRegistry.getProviders.pipe( - Effect.map((providers) => { - const instanceIds: Array = []; - for (const candidate of providers) { - if (candidate.driver === provider && candidate.instanceId === instanceId) { - instanceIds.push(candidate.instanceId); - } - } - return instanceIds; - }), - Effect.flatMap((instanceIds) => - instanceIds.length === 0 - ? providerRegistry.refreshInstance(instanceId) - : Effect.forEach( - instanceIds, - (instanceId) => providerRegistry.refreshInstance(instanceId), - { - concurrency: "unbounded", - discard: true, - }, - ).pipe(Effect.andThen(providerRegistry.getProviders)), - ), + providerRegistry.refreshAfterUpdate(provider, instanceId).pipe( Effect.flatMap((providers) => { const refreshedProviders = providers.filter( (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, @@ -526,6 +505,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { : verifiedProvider.version; return ( !isStillInstalled(verifiedProvider) || + (verifiedProvider.status === "error" && + (verifiedProvider.auth.status !== "unauthenticated" || + !verifiedProvider.version)) || (targetVersion !== undefined && installedVersion?.replace(/^v/, "") !== targetVersion) ); diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 41b5c9b9ca35..358d49afe45c 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + refreshAfterUpdate: () => Effect.succeed(providers), refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), diff --git a/docs/user/updating.md b/docs/user/updating.md index a863f62fdeac..706d8fad5e10 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -118,6 +118,15 @@ directory and pair with the running server. at once. Hover it to see which providers it will update. Providers that only offer a manual update command are not included. +After a successful provider CLI update, T3 Code refreshes model discovery for +every enabled instance of that provider using its own configuration. Connected +clients receive the refreshed catalogs automatically. Provider update checks +must be enabled to download the latest T3 Code model catalog. + +Provider updates do not restart running chats. An existing session can keep +using the previous CLI until it ends. If discovery fails, check the provider +status and refresh it again. + ## Mobile updates To update an environment from your phone, open **Settings → Environments** and From 4883320941f4ae5dac097a9c2eb95098f44fc753 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Tue, 20 Oct 2026 03:20:29 +0100 Subject: [PATCH 040/458] fix(composer): only chip skills offered by the current provider (#14834) Co-authored-by: Julius Marminge --- apps/mobile/src/lib/composerContext.test.ts | 64 +++++- apps/mobile/src/lib/composerContext.ts | 25 ++- .../src/native/T3ComposerEditor.ios.tsx | 9 +- .../src/native/T3ComposerEditor.native.tsx | 9 +- .../src/native/T3ComposerEditor.types.ts | 5 +- ...rPromptEditorTiptap.input.browser.test.tsx | 206 +++++++++++++++++- .../components/ComposerPromptEditorTiptap.tsx | 188 ++++++++++------ .../settings/SettingsFontPreviews.tsx | 7 +- apps/web/src/composer-rich-text-doc.test.ts | 105 ++++----- apps/web/src/composer-rich-text-doc.ts | 191 ++++++++-------- 10 files changed, 559 insertions(+), 250 deletions(-) diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts index 687defa1aa71..b809ebc3ac51 100644 --- a/apps/mobile/src/lib/composerContext.test.ts +++ b/apps/mobile/src/lib/composerContext.test.ts @@ -7,6 +7,7 @@ import { EnvironmentId, ThreadId, type OrchestrationMessageContext, + type ServerProviderSkill, } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { @@ -18,6 +19,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { composerContextEditorTokens, + composerSkillLabels, composerContextSendBlockReason, composerMentionPath, createComposerContextHistory, @@ -162,10 +164,66 @@ describe("mobile composer context", () => { }); it("keeps exact source positions and repeated references alongside existing native tokens", () => { const reference = formatComposerContextReference(terminal); - const text = `Use $playwright and [app.ts](src/app.ts) with ${reference} then ${reference}`; - const tokens = composerContextEditorTokens(text, collectComposerInlineTokens(text)); - expect(tokens.map((token) => token.type)).toEqual(["skill", "mention", "context", "context"]); + const text = `Use $babysit with $disabled $agentOnly $HOME $PATH $anything $notaskill $20 $20k and [app.ts](src/app.ts) with ${reference} then ${reference}`; + const babysit: ServerProviderSkill = { + name: "babysit", + path: "/skills/babysit", + enabled: true, + }; + const disabled = { ...babysit, name: "disabled", enabled: false }; + const agentOnly = { ...babysit, name: "agentOnly", userInvocable: false }; + const skills = [babysit, disabled, agentOnly]; + const tokens = composerContextEditorTokens( + text, + collectComposerInlineTokens(text), + composerSkillLabels(skills), + ); + expect(tokens.map((token) => token.source)).toEqual([ + "$babysit", + "[app.ts](src/app.ts)", + reference, + reference, + ]); for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source); + expect( + composerContextEditorTokens( + text, + collectComposerInlineTokens(text), + composerSkillLabels([]), + ).map((token) => token.type), + ).toEqual(["mention", "context", "context"]); + expect( + composerContextEditorTokens( + text, + collectComposerInlineTokens(text), + composerSkillLabels([{ ...babysit, name: "HOME" }]), + ) + .filter((token) => token.type === "skill") + .map((token) => token.source), + ).toEqual(["$HOME"]); + const promoted = [ + babysit, + { ...disabled, enabled: true }, + { ...agentOnly, userInvocable: true }, + ]; + expect( + composerContextEditorTokens( + text, + collectComposerInlineTokens(text), + composerSkillLabels(promoted), + ) + .filter((token) => token.type === "skill") + .map((token) => token.source), + ).toEqual(["$babysit", "$disabled", "$agentOnly"]); + expect( + composerContextEditorTokens( + text, + collectComposerInlineTokens(text), + composerSkillLabels(skills), + ) + .filter((token) => token.type === "skill") + .map((token) => token.source), + ).toEqual(["$babysit"]); }); it("removes deleted payloads but keeps the screenshot linked to a remaining annotation", () => { diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index 3d10fa3ea321..03e50acf3de5 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -1,3 +1,4 @@ +import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { filePreviewKind } from "@t3tools/shared/filePreview"; import { videoMimeType } from "@t3tools/shared/video"; @@ -10,6 +11,7 @@ import { type PullRequestContextMetadata, type ReviewCommentContextRecord, type ScopedThreadRef, + type ServerProviderSkill, type ThreadContextRecord, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -147,11 +149,30 @@ export function pullRequestComposerContext( }; } -export function composerContextEditorTokens(text: string, tokens: readonly ComposerInlineToken[]) { +export function composerSkillLabels( + skills: ReadonlyArray< + Pick + >, +) { + return new Map( + skills + .filter(isProviderSkillUserInvocable) + .map((skill) => [skill.name, skill.displayName?.trim() || skill.name]), + ); +} + +export function composerContextEditorTokens( + text: string, + tokens: readonly ComposerInlineToken[], + knownSkills: { has(name: string): boolean }, +) { const references = collectComposerContextReferences(text); return [ ...tokens.filter( - (token) => !references.some((ref) => token.start < ref.end && token.end > ref.start), + (token) => + // `$HOME` and other `$name` text that is not one of the provider's skills stays prose. + (token.type !== "skill" || knownSkills.has(token.value)) && + !references.some((ref) => token.start < ref.end && token.end > ref.start), ), ...references.map((ref) => ({ type: "context" as const, diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 1865e7d66c3a..f2d2699f7a89 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,7 +1,7 @@ import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; -import { composerContextEditorTokens } from "../lib/composerContext"; +import { composerContextEditorTokens, composerSkillLabels } from "../lib/composerContext"; import { requireNativeView } from "expo"; import { useCallback, @@ -152,17 +152,14 @@ export function ComposerEditor({ [], ); - const skillLabels = useMemo( - () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), - [skills], - ); + const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); const tokensJson = useMemo(() => { const tokens = collectComposerInlineTokens(props.value, { preserveTrailingFrom: confirmedTokensRef.current, }); confirmedTokensRef.current = tokens; return JSON.stringify( - composerContextEditorTokens(props.value, tokens).map((token) => { + composerContextEditorTokens(props.value, tokens, skillLabels).map((token) => { const record = token.type === "context" ? props.context?.records.find((record) => record.contextId === token.contextId) diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 1263a7ded760..dce1589439ad 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,7 +1,7 @@ import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; -import { composerContextEditorTokens } from "../lib/composerContext"; +import { composerContextEditorTokens, composerSkillLabels } from "../lib/composerContext"; import { requireNativeView } from "expo"; import { TextInputWrapper } from "expo-paste-input"; import { @@ -150,17 +150,14 @@ export function ComposerEditor({ [], ); - const skillLabels = useMemo( - () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), - [skills], - ); + const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); const tokensJson = useMemo(() => { const tokens = collectComposerInlineTokens(props.value, { preserveTrailingFrom: confirmedTokensRef.current, }); confirmedTokensRef.current = tokens; return JSON.stringify( - composerContextEditorTokens(props.value, tokens).map((token) => { + composerContextEditorTokens(props.value, tokens, skillLabels).map((token) => { const record = token.type === "context" ? props.context?.records.find((record) => record.contextId === token.contextId) diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index 0cd326596e07..4e2ddd178b06 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -36,7 +36,10 @@ export interface ComposerEditorProps { }, ) => void; readonly skills?: ReadonlyArray< - Pick & + Pick< + ServerProviderSkill, + "name" | "displayName" | "shortDescription" | "description" | "enabled" | "userInvocable" + > & Partial> >; readonly selection?: ComposerEditorSelection; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 8af19e152c76..63e9fde86f3a 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -1,10 +1,16 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; -import { act, createRef } from "react"; +import { act, createRef, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { detectComposerTrigger } from "~/composer-logic"; import { serializeEditorDoc } from "~/composer-rich-text-doc"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { + ComposerPromptEditor, + type ComposerPromptEditorHandle, + type ComposerPromptEditorProps, +} from "./ComposerPromptEditor"; let root: Root; let container: HTMLDivElement; @@ -22,20 +28,56 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -async function mountEditor(value: string, richTextEnabled = true) { +const babysit: ServerProviderSkill = { + name: "babysit", + path: "/skills/babysit/SKILL.md", + enabled: true, +}; + +function Composer({ + value, + richTextEnabled, + skills, + onChange, +}: { + value: string; + richTextEnabled: boolean; + skills: ReadonlyArray; + onChange?: ComposerPromptEditorProps["onChange"] | undefined; +}) { + const [prompt, setPrompt] = useState({ value, cursor: value.length }); + return ( + ()} + value={prompt.value} + cursor={prompt.cursor} + richTextEnabled={richTextEnabled} + contextRecords={new Map()} + skills={skills} + disabled={false} + placeholder="" + onChange={(...args) => { + setPrompt({ value: args[0], cursor: args[1] }); + onChange?.(...args); + }} + onPaste={() => {}} + /> + ); +} + +async function mountEditor( + value: string, + richTextEnabled = true, + skills: ReadonlyArray = [], + onChange?: ComposerPromptEditorProps["onChange"], +) { await act(async () => root.render( - ()} + {}} - onPaste={() => {}} + skills={skills} + onChange={onChange} />, ), ); @@ -135,3 +177,143 @@ it.each([false, true])( expect(serializeEditorDoc(editor.state.doc).value).toBe('"word"'); }, ); + +it("keeps unknown dollar names editable when restoring and pasting a draft", async () => { + const value = "echo $HOME $PATH $anything $notaskill $20 $20k $babysit "; + const editor = await mountEditor(value, true, [babysit]); + expect(editor.state.doc.textContent).toBe(value.replace("$babysit", "")); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); + await act(async () => { + editor.commands.setTextSelection("echo $HO".length + 1); + editor.commands.insertContent("X"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value.replace("$HOME", "$HOXME")); + await act(async () => { + editor.commands.selectAll(); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", value); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + expect(editor.state.doc.textContent).toBe(value.replace("$babysit", "")); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); +}); + +it("keeps skill queries active before whitespace and line breaks, suppressing only real chips", async () => { + const onChange = vi.fn(); + const editor = await mountEditor("use now\nfirst \n$babysit tail", true, [babysit], onChange); + const trigger = () => { + const [value, , expandedCursor, suppressTrigger] = onChange.mock.lastCall!; + return suppressTrigger ? null : detectComposerTrigger(value, expandedCursor); + }; + await act(async () => { + editor.commands.setTextSelection("use ".length + 1); + typeText(editor, editor.state.selection.from, editor.state.selection.to, "$bab"); + }); + expect.soft(trigger()).toMatchObject({ kind: "skill", query: "bab" }); + await act(async () => { + editor.commands.setTextSelection(editor.state.doc.child(0).nodeSize + 1 + "first ".length); + typeText(editor, editor.state.selection.from, editor.state.selection.to, "$bab"); + }); + expect.soft(trigger()).toMatchObject({ kind: "skill", query: "bab" }); + await act(async () => { + editor.commands.setHardBreak(); + }); + await act(async () => { + editor.commands.setTextSelection(editor.state.selection.from - 1); + }); + expect(editor.state.selection.$from.nodeAfter?.type.name).toBe("hardBreak"); + expect.soft(trigger()).toMatchObject({ kind: "skill", query: "bab" }); + + const chip = serializeEditorDoc(editor.state.doc).runs.find((run) => run.kind === "token")!; + await act(async () => { + editor.commands.setTextSelection(chip.pmPos); + }); + expect(onChange.mock.lastCall![3]).toBe(true); + await act(async () => { + editor.commands.setTextSelection(chip.pmPos + 1); + }); + expect(onChange.mock.lastCall![3]).toBe(true); + expect(trigger()).toBeNull(); +}); + +it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { + const value = "echo $HOME now"; + let editor = await mountEditor(value); + await act(async () => { + editor.commands.setTextSelection("echo $HO".length + 1); + }); + editor = await mountEditor(value, false); + await act(async () => { + editor.commands.insertContent("X"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXME now"); + editor = await mountEditor(value, true); + await act(async () => { + editor.commands.insertContent("Y"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXYME now"); +}); + +it("reconciles provider skills and metadata without losing formatting, caret, or undo", async () => { + const value = "run **use $babysit now** then $HOME"; + const disabled = { ...babysit, enabled: false }; + const agentOnly = { ...babysit, userInvocable: false }; + const editor = await mountEditor(value, true, [disabled]); + expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); + await mountEditor(value, true, [agentOnly]); + expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); + await act(async () => { + editor.commands.setTextSelection("run use $baby".length + 1); + }); + await mountEditor(value, true, [disabled, babysit]); + expect(editor.state.doc.textContent).toBe("run use now then $HOME"); + const chip = serializeEditorDoc(editor.state.doc).runs.find((run) => run.kind === "token")!; + expect(editor.state.selection.from).toBe(chip.pmPos + 1); + await mountEditor(value, true, [disabled]); + expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); + await act(async () => { + editor.commands.insertContent("X"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value.replace("$babysit", "$babysitX")); + await act(async () => { + editor.commands.undo(); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); + expect(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); + + await mountEditor(value, true, [babysit]); + await act(async () => { + editor.commands.deleteRange({ from: chip.pmPos, to: chip.pmPos + 1 }); + }); + await mountEditor(value, true, [agentOnly]); + await act(async () => { + editor.commands.undo(); + }); + expect(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); + + await mountEditor(value, true, [{ ...babysit, description: "Previous provider description" }]); + await act(async () => { + container + .querySelector('[aria-label="Skill Babysit. Show details"]')! + .click(); + }); + expect(document.body.textContent).toContain("Previous provider description"); + await mountEditor(value, true, [ + { ...disabled, displayName: "Disabled Babysit" }, + { + ...babysit, + displayName: "Current Babysit", + shortDescription: " Current short description ", + }, + ]); + expect(container.textContent).toContain("Current Babysit"); + expect(document.body.textContent).toContain("Current short description"); + expect(document.body.textContent).not.toContain("Previous provider description"); + await mountEditor(value, true, [{ ...babysit, displayName: "Current Babysit" }]); + expect(container.textContent).toContain("Current Babysit"); + expect(document.body.textContent).not.toContain("Current short description"); + expect(document.body.textContent).toContain("No description is available for this skill."); + expect(document.body.textContent).not.toContain("Previous provider description"); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 92915b0c9424..341852377ee7 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,3 +1,4 @@ +import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { Extension, @@ -11,6 +12,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tip import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; +import { isHistoryTransaction } from "@tiptap/pm/history"; import { type EditorState, NodeSelection, @@ -50,7 +52,6 @@ import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, expandCollapsedComposerCursor, - isCollapsedCursorAdjacentToInlineToken, pastedPathQueryLength, } from "~/composer-logic"; import { useStableCallback } from "~/hooks/useStableCallback"; @@ -64,18 +65,18 @@ import { ComposerCodeBlockExtension, ComposerListExtensions, buildTiptapContent, - collapsedToFlat, caretTakesMarksBefore, convertBulletItemToTask, ComposerCodeExtension, ComposerTaskItemExtension, ComposerTaskListExtension, - flatToCollapsed, flatToMarkdown, flatToPm, + markdownToFlat, pmToFlat, serializeEditorDoc, serializeSelection, + skillChipReplacements, splitOrLiftListItem, stepCaretAcrossStyledEdge, type SkillMeta, @@ -316,13 +317,37 @@ const ComposerSkillExtension = Node.create({ }, }); +/** Chip presentation for `$name`, or null when the provider does not offer it to users. */ +function composerSkillMeta( + skills: ReadonlyArray, + name: string, +): SkillMeta | null { + const skill = skills.find( + (candidate) => candidate.name === name && isProviderSkillUserInvocable(candidate), + ); + if (!skill) return null; + const shortDescription = skill.shortDescription?.trim(); + return { + label: formatProviderSkillDisplayName(skill), + description: shortDescription || skill.description?.trim() || null, + }; +} + function ComposerSkillNodeView({ node }: NodeViewProps) { const actions = use(ComposerContextActionsContext); const skills = use(RichComposerSkillsContext); const skillName = (node.attrs.skillName as string) ?? ""; - const skillLabel = (node.attrs.skillLabel as string) || skillName; const skillDescription = (node.attrs.skillDescription as string | null) ?? null; - const skill = skills.find((candidate) => candidate.name === skillName); + const skill = skills.find( + (candidate) => candidate.name === skillName && isProviderSkillUserInvocable(candidate), + ); + const description = skill + ? skill.description?.trim() || skill.shortDescription?.trim() + : skillDescription; + // The node outlives a provider switch, so its stored label can be another provider's. + const skillLabel = skill + ? formatProviderSkillDisplayName(skill) + : (node.attrs.skillLabel as string) || skillName; return (

    {skillLabel}

    -

    - {skill?.description ?? - skillDescription ?? - "No description is available for this skill."} -

    - {skill?.path ? ( +

    {description || "No description is available for this skill."}

    + {skill?.path && actions.environmentId ? ( @@ -743,12 +764,43 @@ const ComposerMarkersExtension = Extension.create({ type TiptapEditor = NonNullable>; +function replaceSkillChips( + editor: TiptapEditor, + replacements: ReturnType, +): void { + const transaction = editor.state.tr.setMeta("addToHistory", false); + for (const { from, to, node } of replacements) transaction.replaceWith(from, to, node); + editor.view.dispatch(transaction); +} + export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { - // Extensions are creation-time, so changing text mode remounts the editor. + const { onChange: onPromptChange } = props; + const [selection, setSelection] = useState<{ + value: string; + cursor: number; + expandedCursor: number; + } | null>(null); + const onChange: ComposerPromptEditorProps["onChange"] = ( + value, + cursor, + expandedCursor, + ...rest + ) => { + setSelection({ value, cursor, expandedCursor }); + onPromptChange(value, cursor, expandedCursor, ...rest); + }; + // Changing text mode remounts the editor. Its collapsed cursor cannot represent + // a caret inside unknown `$name` text, so retain the exact Markdown offset here. return ( ); } @@ -879,7 +931,9 @@ function createComposerExtensions(richText: boolean) { const RICH_TEXT_EXTENSIONS = createComposerExtensions(true); const PLAIN_TEXT_EXTENSIONS = createComposerExtensions(false); -function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { +function ComposerPromptEditorTiptapInner( + props: ComposerPromptEditorProps & { initialExpandedCursor: number }, +) { const { value, cursor, @@ -952,21 +1006,10 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }, [value]); // Stable so a skills refresh does not rerun the controlled-value effect. - const skillLabelFor = useStableCallback((name: string): SkillMeta => { - const normalized = name.startsWith("$") ? name.slice(1) : name; - const skill = skills.find((candidate) => candidate.name === normalized); - if (!skill) { - return { label: formatProviderSkillDisplayName({ name: normalized }), description: null }; - } - const shortDescription = skill.shortDescription?.trim(); - return { - label: formatProviderSkillDisplayName(skill), - description: shortDescription || skill.description?.trim() || null, - }; - }); + const skillLabelFor = useStableCallback((name: string) => composerSkillMeta(skills, name)); const initialCursor = clampEditorCursor(value, cursor); - const initialExpandedCursor = expandEditorCursor(value, initialCursor); + const initialExpandedCursor = Math.max(0, Math.min(value.length, props.initialExpandedCursor)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -1006,11 +1049,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const fromFlat = pmToFlat(map, from); const toFlat = pmToFlat(map, to); const nextValue = map.value; - const nextCursor = clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)); const nextExpandedCursor = Math.max( 0, Math.min(map.value.length, flatToMarkdown(map, fromFlat)), ); + const nextCursor = collapseExpandedComposerCursor(map.value, nextExpandedCursor, literalText); const nextSelectionRange = { start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), @@ -1049,8 +1092,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const suppressTrigger = literalText || inCodeBlock || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); + [updated.state.selection.$from.nodeBefore, updated.state.selection.$from.nodeAfter].some( + (node) => node?.isInline && node.isAtom && !node.isText && node.type.name !== "hardBreak", + ); onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); }; @@ -1083,25 +1127,10 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const editor = useEditor( { extensions: richText ? RICH_TEXT_EXTENSIONS : PLAIN_TEXT_EXTENSIONS, - content: buildDocJson( - value, - (name) => { - const normalized = name.startsWith("$") ? name.slice(1) : name; - const found = skills.find((candidate) => candidate.name === normalized); - if (!found) { - return { - label: formatProviderSkillDisplayName({ name: normalized }), - description: null, - }; - } - const shortDescription = found.shortDescription?.trim(); - return { - label: formatProviderSkillDisplayName(found), - description: shortDescription || found.description?.trim() || null, - }; - }, - { styling: richText, literalText }, - ), + content: buildDocJson(value, (name) => composerSkillMeta(skills, name), { + styling: richText, + literalText, + }), editable: !disabled, editorProps: { attributes: editorAttributes, @@ -1370,17 +1399,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ? importPastedComposerText(clipboardData, importFragment) : pastedText; const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); + const isChip = (token: (typeof tokens)[number] | undefined) => + token?.type === "mention" || + (token?.type === "skill" && skillLabelFor(token.value) !== null); const lastToken = tokens.at(-1); - if ( - (lastToken?.type === "mention" || lastToken?.type === "skill") && - lastToken.end === text.length - ) { + if (isChip(lastToken) && lastToken?.end === text.length) { text += " "; } - if ( - (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") && - tokens[0].start === 0 - ) { + if (isChip(tokens[0]) && tokens[0]?.start === 0) { const map = serializeEditorDoc(view.state.doc); const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; @@ -1430,7 +1456,17 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; }, }, - onUpdate: ({ editor: updated }) => { + onUpdate: ({ editor: updated, transaction }) => { + // Undo can restore a chip for a skill the current provider no longer offers. + if (!literalText && isHistoryTransaction(transaction)) { + const stale = skillChipReplacements(updated.state.doc, skillLabelFor).filter( + ({ node }) => node.isText, + ); + if (stale.length > 0) { + replaceSkillChips(updated, stale); + return; + } + } handleEditorChange(updated); }, onSelectionUpdate: ({ editor: updated }) => { @@ -1465,10 +1501,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const map = serializeEditorDoc(editor.state.doc); const { from, to } = editor.state.selection; const fromFlat = pmToFlat(map, from); + const expandedCursor = Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))); const next: typeof snapshot = { value: map.value, - cursor: clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)), - expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), + cursor: collapseExpandedComposerCursor(map.value, expandedCursor, literalText), + expandedCursor, contextIds: literalText ? [] : map.contextIds, }; const toFlat = pmToFlat(map, to); @@ -1526,7 +1563,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { return; } - const normalizedExpandedCursor = expandEditorCursor(value, normalizedCursor); + const normalizedExpandedCursor = initialSelection + ? initialExpandedCursor + : expandEditorCursor(value, normalizedCursor); snapshotRef.current = { value, cursor: normalizedCursor, @@ -1555,7 +1594,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ); } const map = serializeEditorDoc(editor.state.doc); - const flat = collapsedToFlat(map, normalizedCursor); + const flat = markdownToFlat(map, normalizedExpandedCursor); editor.commands.setTextSelection(flatToPm(map, flat)); if (isFocused) scrollTiptapCaretIntoView(editor); if (pendingCitation) { @@ -1586,13 +1625,23 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { cursor, editor, expandEditorCursor, + initialExpandedCursor, literalText, richText, skillLabelFor, value, ]); - const focusAt = (nextCursor: number) => { + // Reconcile loaded or switched provider skills without rebuilding citation node views. + useEffect(() => { + if (!editor || literalText) return; + const replacements = skillChipReplacements(editor.state.doc, (name) => + composerSkillMeta(skills, name), + ); + if (replacements.length > 0) replaceSkillChips(editor, replacements); + }, [editor, literalText, skills]); + + const focusAt = (nextCursor: number, exactExpandedCursor?: number) => { if (!editor) return; // view.focus() writes the state selection to the DOM as it focuses. A // bare DOM focus leaves the native caret at the start until @@ -1601,15 +1650,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { editor.view.focus(); if (snapshotRef.current.value !== latestValueRef.current) return; const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor); + const expandedCursor = + exactExpandedCursor ?? expandEditorCursor(snapshotRef.current.value, boundedCursor); const map = serializeEditorDoc(editor.state.doc); - const flat = collapsedToFlat(map, boundedCursor); - editor.commands.setTextSelection(flatToPm(map, flat)); + editor.commands.setTextSelection(flatToPm(map, markdownToFlat(map, expandedCursor))); scrollTiptapCaretIntoView(editor); if (boundedCursor === snapshotRef.current.cursor) return; snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, - expandedCursor: expandEditorCursor(snapshotRef.current.value, boundedCursor), + expandedCursor, contextIds: snapshotRef.current.contextIds, }; selectionRangeRef.current = { @@ -1629,9 +1679,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { editorRef, () => ({ focus: () => { - focusAt(snapshotRef.current.cursor); + // The collapsed cursor cannot say where inside `$name` text a caret + // sits, so restoring the caret passes along its exact offset. + focusAt(snapshotRef.current.cursor, snapshotRef.current.expandedCursor); }, - focusAt, + focusAt: (cursor) => focusAt(cursor), focusAtEnd: () => { focusAt( literalText @@ -1808,7 +1860,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { */ function insertMarkdownParagraphs( value: string, - skillLabelFor: (name: string) => SkillMeta, + skillLabelFor: (name: string) => SkillMeta | null, options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index b6f42c5c6b2e..e6f91e88a180 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -1,3 +1,4 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; import { preloadPatchFile } from "@pierre/diffs/ssr"; import { useEffect, useRef, useState } from "react"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; @@ -9,7 +10,9 @@ import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRenderin import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; -const EMPTY_SKILLS: ReadonlyArray = []; +const PREVIEW_SKILLS: ReadonlyArray = [ + { name: "frontend-design", path: ".agents/skills/frontend-design/SKILL.md", enabled: true }, +]; const PROMPT_PREVIEW_TEXT = "Use $frontend-design to fix the flaky test in " + @@ -34,7 +37,7 @@ export function PromptFontPreview() { value={prompt} cursor={cursor} contextRecords={EMPTY_COMPOSER_CONTEXT_RECORDS} - skills={EMPTY_SKILLS} + skills={PREVIEW_SKILLS} disabled={false} placeholder={DISCONNECTED_COMPOSER_PLACEHOLDER} className="max-h-42 min-h-14" diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 9b0cbf5cf385..87197f52e4d1 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -6,7 +6,11 @@ import { EditorState, TextSelection } from "@tiptap/pm/state"; import { splitBlockKeepMarks } from "@tiptap/pm/commands"; import { describe, expect, it, onTestFinished } from "vite-plus/test"; -import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic"; +import { + collapseExpandedComposerCursor, + expandCollapsedComposerCursor, + pastedPathQueryLength, +} from "./composer-logic"; import { buildDocJson, @@ -15,15 +19,15 @@ import { ComposerBlockExtensions, ComposerCodeBlockExtension, ComposerListExtensions, - collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, - flatToCollapsed, flatToMarkdown, flatToPm, + markdownToFlat, pmToFlat, serializeEditorDoc, serializeSelection, + skillChipReplacements, stepCaretAcrossStyledEdge, } from "./composer-rich-text-doc"; @@ -205,7 +209,9 @@ describe("literal editor answers", () => { expect(serialized.contextIds).toEqual([]); expect(serializeSelection(doc, 1, doc.content.size - 1)).toBe(value); for (let offset = 0; offset <= value.length; offset += 1) { - expect(flatToCollapsed(serialized, offset)).toBe(offset); + expect(collapseExpandedComposerCursor(value, flatToMarkdown(serialized, offset), true)).toBe( + offset, + ); } }); @@ -301,7 +307,7 @@ describe("composer rich text document model", () => { const position = flatToPm(map, flat); expect(doc.resolve(position).parent.isTextblock).toBe(true); expect(pmToFlat(map, position)).toBe(flat); - expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); } }); @@ -412,7 +418,7 @@ describe("composer rich text document model", () => { expect(serializeEditorDoc(rebuilt).value).toBe(map.value); for (let flat = 0; flat <= map.docLength; flat += 1) { expect(pmToFlat(map, flatToPm(map, flat))).toBe(flat); - expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); if (flat < map.docLength && !/\s/.test(doc.textContent[flat]!)) { expect(rebuilt.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name)).toEqual( doc.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name), @@ -490,12 +496,12 @@ describe("composer rich text document model", () => { }); it.each(["- one\n- two", "1. a\n - b\n2. c", "- **bold** @README.md tail", "-"])( - "maps every document offset of the list %s through collapsed coordinates and back", + "maps every document offset of the list %s through markdown coordinates and back", (value) => { const map = roundTrip(value); expect(map.value).toBe(value); for (let flat = 0; flat <= map.docLength; flat += 1) { - expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); } }, ); @@ -503,11 +509,11 @@ describe("composer rich text document model", () => { it("clamps offsets inside a list marker to the start of the item text", () => { const value = "- item"; const map = roundTrip(value); - for (let collapsed = 0; collapsed <= "- ".length; collapsed += 1) { - expect(collapsedToFlat(map, collapsed)).toBe(0); + for (let markdown = 0; markdown <= "- ".length; markdown += 1) { + expect(markdownToFlat(map, markdown)).toBe(0); } - expect(collapsedToFlat(map, "- it".length)).toBe(2); - expect(flatToCollapsed(map, 0)).toBe("- ".length); + expect(markdownToFlat(map, "- it".length)).toBe(2); + expect(flatToMarkdown(map, 0)).toBe("- ".length); }); it.each([ @@ -577,13 +583,13 @@ describe("composer rich text document model", () => { it("keeps every offset mapped through the blank line", () => { const { doc, map } = serialize(quote("quoted"), paragraph("Comment")); for (let flat = 0; flat <= map.docLength; flat += 1) { - expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); } // The caret on the end of the quote stays on it, and the blank line is // not a place to type: it clamps to the start of the comment. - expect(flatToCollapsed(map, "quoted".length)).toBe("> quoted".length); - expect(collapsedToFlat(map, "> quoted\n".length)).toBe("quoted".length + 1); - expect(flatToCollapsed(map, "quoted".length + 1)).toBe("> quoted\n\n".length); + expect(flatToMarkdown(map, "quoted".length)).toBe("> quoted".length); + expect(markdownToFlat(map, "> quoted\n".length)).toBe("quoted".length + 1); + expect(flatToMarkdown(map, "quoted".length + 1)).toBe("> quoted\n\n".length); expect(flatToPm(map, "quoted".length + 1)).toBe(doc.child(0).nodeSize + 1); }); }); @@ -729,12 +735,12 @@ describe("composer rich text document model", () => { "# Heading text", "## **b** @README.md", ])( - "maps every document offset of the block %s through collapsed coordinates and back", + "maps every document offset of the block %s through markdown coordinates and back", (value) => { const map = roundTrip(value); expect(map.value).toBe(value); for (let flat = 0; flat <= map.docLength; flat += 1) { - expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); } }, ); @@ -745,10 +751,10 @@ describe("composer rich text document model", () => { ["# Heading", "# "], ] as const) { const map = roundTrip(value); - for (let collapsed = 0; collapsed <= prefix.length; collapsed += 1) { - expect(collapsedToFlat(map, collapsed)).toBe(0); + for (let markdown = 0; markdown <= prefix.length; markdown += 1) { + expect(markdownToFlat(map, markdown)).toBe(0); } - expect(flatToCollapsed(map, 0)).toBe(prefix.length); + expect(flatToMarkdown(map, 0)).toBe(prefix.length); } }); @@ -774,12 +780,12 @@ describe("composer rich text document model", () => { }); it.each(["```ts\nconst a = 1;\n```", "```\n```", "before\n```ts\ncode\n```\nafter"])( - "maps every document offset of %s through collapsed coordinates and back", + "maps every document offset of %s through markdown coordinates and back", (value) => { const map = roundTrip(value); expect(map.value).toBe(value); for (let flat = 0; flat <= map.docLength; flat += 1) { - expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); } }, ); @@ -801,17 +807,16 @@ describe("composer rich text document model", () => { const value = "```ts\nfunc();\n```"; const map = roundTrip(value); const endOfCode = "func();".length; - expect(flatToCollapsed(map, endOfCode)).toBe("```ts\nfunc();".length); expect(flatToMarkdown(map, endOfCode)).toBe("```ts\nfunc();".length); - expect(collapsedToFlat(map, flatToCollapsed(map, endOfCode))).toBe(endOfCode); + expect(markdownToFlat(map, flatToMarkdown(map, endOfCode))).toBe(endOfCode); }); it("keeps the caret inside an empty fence", () => { const value = "before\n```\n```"; const map = roundTrip(value); const inside = "before\n".length; - expect(flatToCollapsed(map, inside)).toBe("before\n```".length); - expect(collapsedToFlat(map, flatToCollapsed(map, inside))).toBe(inside); + expect(flatToMarkdown(map, inside)).toBe("before\n```".length); + expect(markdownToFlat(map, flatToMarkdown(map, inside))).toBe(inside); }); it("still places the end of an inline mark after its markers", () => { @@ -823,12 +828,12 @@ describe("composer rich text document model", () => { const value = "```ts\nab\n```"; const map = roundTrip(value); expect(map.value).toBe(value); - for (let collapsed = 0; collapsed <= "```ts\n".length; collapsed += 1) { - expect(collapsedToFlat(map, collapsed)).toBe(0); + for (let markdown = 0; markdown <= "```ts\n".length; markdown += 1) { + expect(markdownToFlat(map, markdown)).toBe(0); } - expect(collapsedToFlat(map, "```ts\na".length)).toBe(1); - for (let collapsed = "```ts\nab".length; collapsed <= value.length; collapsed += 1) { - expect(collapsedToFlat(map, collapsed)).toBe(2); + expect(markdownToFlat(map, "```ts\na".length)).toBe(1); + for (let markdown = "```ts\nab".length; markdown <= value.length; markdown += 1) { + expect(markdownToFlat(map, markdown)).toBe(2); } }); @@ -910,19 +915,18 @@ describe("composer rich text document model", () => { expect(roundTrip("```\ncode").value).toBe("```\ncode"); }); - it("counts a chip's source in a fence as one cursor position, as the draft store does", () => { - const value = "```\nsee @b.md now\n```\nafter"; - const map = roundTrip(value); + it("keeps a chip's source in a fence as literal text", () => { + const value = "```\nsee @b.md and $babysit now\n```\nafter"; + const known = () => ({ label: "Babysit", description: null }); + const doc = ProseMirrorNode.fromJSON(schema, buildDocJson(value, known)); + const map = serializeEditorDoc(doc); expect(map.value).toBe(value); + expect(map.runs.filter((run) => run.kind === "token")).toEqual([]); for (let flat = 0; flat <= map.docLength; flat += 1) { - expect(flatToCollapsed(map, flat)).toBe( - collapseExpandedComposerCursor(value, flatToMarkdown(map, flat)), - ); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); } - const afterChip = "see @b.md".length; - expect(collapsedToFlat(map, flatToCollapsed(map, afterChip))).toBe(afterChip); - const end = map.docLength; - expect(collapsedToFlat(map, flatToCollapsed(map, end))).toBe(end); + // Reconciling against the provider's skills leaves the code alone too. + expect(skillChipReplacements(doc, known)).toEqual([]); }); it("keeps fences literal in plain mode", () => { @@ -930,12 +934,12 @@ describe("composer rich text document model", () => { expect(roundTripPlain(value).value).toBe(value); }); - it("maps every document offset through collapsed coordinates and back", () => { + it("maps every document offset through markdown coordinates and back", () => { const value = "hi **bold** @README.md bye"; const map = roundTrip(value); expect(map.value).toBe(value); for (let flat = 0; flat <= map.docLength; flat += 1) { - expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); } }); @@ -950,8 +954,9 @@ describe("composer rich text document model", () => { expect(rich.value).toBe(value); expect(plain.value).toBe(value); for (let collapsed = 0; collapsed <= value.length; collapsed += 1) { - expect(flatToMarkdown(rich, collapsedToFlat(rich, collapsed))).toBe( - flatToMarkdown(plain, collapsedToFlat(plain, collapsed)), + const markdown = expandCollapsedComposerCursor(value, collapsed); + expect(flatToMarkdown(rich, markdownToFlat(rich, markdown))).toBe( + flatToMarkdown(plain, markdownToFlat(plain, markdown)), ); } }); @@ -960,8 +965,8 @@ describe("composer rich text document model", () => { const value = "a **bold** c"; const plain = roundTripPlain(value); const rich = roundTrip(value); - expect(flatToMarkdown(plain, collapsedToFlat(plain, 3))).toBe(3); - expect(flatToMarkdown(rich, collapsedToFlat(rich, 3))).toBe(4); + expect(flatToMarkdown(plain, markdownToFlat(plain, 3))).toBe(3); + expect(flatToMarkdown(rich, markdownToFlat(rich, 3))).toBe(4); }); it("maps markdown offsets at styled edges onto document text", () => { @@ -970,8 +975,8 @@ describe("composer rich text document model", () => { expect(map.value).toBe(value); expect(flatToMarkdown(map, 2)).toBe(4); expect(flatToMarkdown(map, 6)).toBe(10); - expect(collapsedToFlat(map, 3)).toBe(2); - expect(collapsedToFlat(map, 9)).toBe(6); + expect(markdownToFlat(map, 3)).toBe(2); + expect(markdownToFlat(map, 9)).toBe(6); }); }); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index bef884c18629..8fffe16573bd 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -432,10 +432,11 @@ interface DocLine { inline: InlineJson[]; } +/** Null for a `$name` that is not one of the provider's skills: it stays editable text. */ function atomJsonForSegment( segment: Exclude[number], { type: "text" }>, - skillLabelFor: (name: string) => SkillMeta, -): InlineJson { + skillLabelFor: (name: string) => SkillMeta | null, +): InlineJson | null { if (segment.type === "mention") { return { type: "composer-mention", @@ -444,6 +445,7 @@ function atomJsonForSegment( } if (segment.type === "skill") { const meta = skillLabelFor(segment.name); + if (!meta) return null; return { type: "composer-skill", attrs: { @@ -534,7 +536,7 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, + skillLabelFor: (name: string) => SkillMeta | null, options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, ): Record[] { if (options?.literalText) { @@ -573,7 +575,9 @@ export function buildTiptapContent( return splitPromptIntoComposerSegments(chunk.text) .map((segment) => { if (segment.type === "text") return segment.text; - atoms.push(atomJsonForSegment(segment, skillLabelFor)); + const atom = atomJsonForSegment(segment, skillLabelFor); + if (!atom) return segment.source; + atoms.push(atom); atomSources.push(segment.source); return sentinel; }) @@ -770,7 +774,7 @@ export function buildTiptapContent( export function buildDocJson( value: string, - skillLabelFor: (name: string) => SkillMeta, + skillLabelFor: (name: string) => SkillMeta | null, options?: { styling?: boolean; literalText?: boolean }, ) { const content = buildTiptapContent(value, skillLabelFor, options); @@ -783,15 +787,12 @@ export interface RichRun { /** Flat document offset (atoms count 1, markers excluded). */ flatStart: number; docLen: number; - /** Collapsed cursor length (markers literal, tokens count 1). */ - collapsedLen: number; /** Markdown length (tokens expand to their source). */ mdLen: number; openLen: number; closeLen: number; pmPos: number; mdStart: number; - collapsedStart: number; nodeName?: string; } @@ -822,7 +823,6 @@ interface RichAccumulator { runs: RichRun[]; value: string; flat: number; - collapsed: number; md: number; } @@ -833,17 +833,14 @@ function pushBreakRun(acc: RichAccumulator, position?: number): void { kind: "break", flatStart: acc.flat, docLen: 1, - collapsedLen: 1, mdLen: 1, openLen: 0, closeLen: 0, pmPos, mdStart: acc.md, - collapsedStart: acc.collapsed, }); acc.value += "\n"; acc.flat += 1; - acc.collapsed += 1; acc.md += 1; } @@ -867,16 +864,13 @@ function pushBlankLineRun(acc: RichAccumulator, pmPos: number): void { kind: "prefix", flatStart: acc.flat, docLen: 0, - collapsedLen: 1, mdLen: 1, openLen: 0, closeLen: 0, pmPos, mdStart: acc.md, - collapsedStart: acc.collapsed, }); acc.value += "\n"; - acc.collapsed += 1; acc.md += 1; } @@ -983,23 +977,19 @@ function appendInlineRuns( const source = child.isText ? child.text! : readAtomSource(child); const docLen = child.isText ? source.length : 1; const mdText = open + source + close; - const collapsedLen = open.length + docLen + close.length; acc.runs.push({ kind: child.isText ? "text" : "token", flatStart: acc.flat, docLen, - collapsedLen, mdLen: mdText.length, openLen: open.length, closeLen: close.length, pmPos, mdStart: acc.md, - collapsedStart: acc.collapsed, ...(child.isText ? {} : { nodeName: child.type.name }), }); acc.value += mdText; acc.flat += docLen; - acc.collapsed += collapsedLen; acc.md += mdText.length; }); if (children.length === 0) { @@ -1007,13 +997,11 @@ function appendInlineRuns( kind: "text", flatStart: acc.flat, docLen: 0, - collapsedLen: 0, mdLen: 0, openLen: 0, closeLen: 0, pmPos: contentStart, mdStart: acc.md, - collapsedStart: acc.collapsed, }); } } @@ -1202,16 +1190,13 @@ function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator kind: "prefix", flatStart: acc.flat, docLen: 0, - collapsedLen: prefix.length, mdLen: prefix.length, openLen: 0, closeLen: 0, pmPos: itemContentStart + 1, mdStart: acc.md, - collapsedStart: acc.collapsed, }); acc.value += prefix; - acc.collapsed += prefix.length; acc.md += prefix.length; let childPos = itemContentStart; let firstBlock = true; @@ -1229,12 +1214,6 @@ function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator }); } -/** - * The code is literal text in the editor, but the draft store still counts a - * chip's source in it as one cursor position, as it does everywhere. So the - * run splits at each chip source, which spans its characters in the document - * and one position in the collapsed space. - */ function appendCodeBlockRun( block: ProseMirrorNode, pmPos: number, @@ -1242,37 +1221,20 @@ function appendCodeBlockRun( acc: RichAccumulator, ): void { const { open, content, close } = codeBlockSource(block, followed); - const pieces = content - ? splitPromptIntoComposerSegments(content).map((segment) => - segment.type === "text" - ? { length: segment.text.length, collapsedLen: segment.text.length } - : { length: segment.source.length, collapsedLen: 1 }, - ) - : [{ length: 0, collapsedLen: 0 }]; - let offset = 0; - pieces.forEach((piece, index) => { - const openLen = index === 0 ? open.length : 0; - const closeLen = index === pieces.length - 1 ? close.length : 0; - const collapsedLen = openLen + piece.collapsedLen + closeLen; - const mdLen = openLen + piece.length + closeLen; - acc.runs.push({ - kind: "text", - flatStart: acc.flat, - docLen: piece.length, - collapsedLen, - mdLen, - openLen, - closeLen, - pmPos: pmPos + offset, - mdStart: acc.md, - collapsedStart: acc.collapsed, - nodeName: "codeBlock", - }); - offset += piece.length; - acc.flat += piece.length; - acc.collapsed += collapsedLen; - acc.md += mdLen; + const mdLen = open.length + content.length + close.length; + acc.runs.push({ + kind: "text", + flatStart: acc.flat, + docLen: content.length, + mdLen, + openLen: open.length, + closeLen: close.length, + pmPos, + mdStart: acc.md, + nodeName: "codeBlock", }); + acc.flat += content.length; + acc.md += mdLen; acc.value += open + content + close; } @@ -1288,16 +1250,13 @@ function walkBlockquote(quote: ProseMirrorNode, quoteStart: number, acc: RichAcc kind: "prefix", flatStart: acc.flat, docLen: 0, - collapsedLen: prefix.length, mdLen: prefix.length, openLen: 0, closeLen: 0, pmPos: childPos + 1, mdStart: acc.md, - collapsedStart: acc.collapsed, }); acc.value += prefix; - acc.collapsed += prefix.length; acc.md += prefix.length; if (child.type.name === "paragraph") appendInlineRuns(child, childPos + 1, acc); childPos += child.nodeSize; @@ -1327,7 +1286,7 @@ export function serializeSelection(doc: ProseMirrorNode, from: number, to: numbe } export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { - const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; + const acc: RichAccumulator = { runs: [], value: "", flat: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; doc.content.forEach((node) => { blocks.push(node); @@ -1354,16 +1313,13 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { kind: "prefix", flatStart: acc.flat, docLen: 0, - collapsedLen: prefix.length, mdLen: prefix.length, openLen: 0, closeLen: 0, pmPos: pmBlockStart + 1, mdStart: acc.md, - collapsedStart: acc.collapsed, }); acc.value += prefix; - acc.collapsed += prefix.length; acc.md += prefix.length; appendInlineRuns(block, pmBlockStart + 1, acc); } else if (block.type.name === "horizontalRule") { @@ -1373,16 +1329,13 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { kind: "prefix", flatStart: acc.flat, docLen: 0, - collapsedLen: source.length, mdLen: source.length, openLen: 0, closeLen: 0, pmPos: pmBlockStart + block.nodeSize, mdStart: acc.md, - collapsedStart: acc.collapsed, }); acc.value += source; - acc.collapsed += source.length; acc.md += source.length; } else if (block.type.name === "paragraph") { appendInlineRuns(block, pmBlockStart + 1, acc); @@ -1398,12 +1351,64 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { }; } -function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { - const last = map.runs[map.runs.length - 1]; - if (!last) return 0; - return space === "collapsed" - ? last.collapsedStart + last.collapsedLen - : last.mdStart + last.mdLen; +export interface SkillChipReplacement { + from: number; + to: number; + node: ProseMirrorNode; +} + +/** + * Use the same Markdown token boundaries as draft restoration. Apply edits + * last-first so positions stay valid and unrelated citation nodes keep their keys. + */ +export function skillChipReplacements( + doc: ProseMirrorNode, + skillLabelFor: (name: string) => SkillMeta | null, +): SkillChipReplacement[] { + const schema = doc.type.schema; + const skillType = schema.nodes["composer-skill"]; + if (!skillType) return []; + const replacements: SkillChipReplacement[] = []; + doc.descendants((node, pos) => { + if (node.type !== skillType) return; + const name = typeof node.attrs.skillName === "string" ? node.attrs.skillName : ""; + if (name && !skillLabelFor(name)) { + replacements.push({ + from: pos, + to: pos + node.nodeSize, + node: schema.text(readAtomSource(node), node.marks), + }); + } + }); + const map = serializeEditorDoc(doc); + let end = 0; + for (const segment of splitPromptIntoComposerSegments(map.value)) { + const start = end; + end += segment.type === "text" ? segment.text.length : segment.source.length; + if (segment.type !== "skill") continue; + const meta = skillLabelFor(segment.name); + if (!meta) continue; + // Only text needs converting; an existing chip is a token run. + const run = map.runs.find( + (candidate) => + candidate.kind === "text" && + candidate.nodeName !== "codeBlock" && + start >= candidate.mdStart + candidate.openLen && + end <= candidate.mdStart + candidate.openLen + candidate.docLen, + ); + if (!run) continue; + const from = run.pmPos + (start - run.mdStart - run.openLen); + replacements.push({ + from, + to: from + segment.source.length, + node: skillType.create( + { skillName: segment.name, skillLabel: meta.label, skillDescription: meta.description }, + null, + doc.nodeAt(from)?.marks, + ), + }); + } + return replacements.sort((left, right) => right.from - left.from); } /** @@ -1417,23 +1422,6 @@ function runOwnsOffset(run: RichRun, flatOffset: number): boolean { return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock"); } -export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { - const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); - for (const run of map.runs) { - if (runOwnsOffset(run, bounded)) { - if (run.kind === "text" || run.kind === "token") { - const within = Math.min( - bounded - run.flatStart, - run.collapsedLen - run.openLen - run.closeLen, - ); - return run.collapsedStart + run.openLen + within; - } - return run.collapsedStart + (bounded - run.flatStart); - } - } - return lastRunEnd(map, "collapsed"); -} - export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { @@ -1444,21 +1432,24 @@ export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { return run.mdStart + (bounded - run.flatStart); } } - return lastRunEnd(map, "md"); + const last = map.runs[map.runs.length - 1]; + return last ? last.mdStart + last.mdLen : 0; } -export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): number { +export function markdownToFlat(map: RichDocMap, markdownOffset: number): number { for (const run of map.runs) { - if (collapsedOffset < run.collapsedStart + run.collapsedLen) { + if (markdownOffset < run.mdStart + run.mdLen) { if (run.kind === "prefix") return run.flatStart; if (run.kind === "text" || run.kind === "token") { - const within = collapsedOffset - run.collapsedStart; - const contentLen = run.collapsedLen - run.openLen - run.closeLen; + const within = markdownOffset - run.mdStart; if (within <= run.openLen) return run.flatStart; - if (within >= run.openLen + contentLen) return run.flatStart + run.docLen; + // A chip is one document position however long its source is. + if (run.kind === "token" || within >= run.openLen + run.docLen) { + return run.flatStart + run.docLen; + } return run.flatStart + (within - run.openLen); } - return run.flatStart + (collapsedOffset - run.collapsedStart); + return run.flatStart + (markdownOffset - run.mdStart); } } return map.docLength; From ab47250ec1e690d16f2349af622b0f5391503346 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:27:52 -0700 Subject: [PATCH 041/458] feat(relay): record the Cloudflare edge each request entered through (#22080) Co-authored-by: Claude Opus 5.5 --- infra/relay/src/http/Api.test.ts | 34 ++++++++++++++++++++++++++++++++ infra/relay/src/http/Api.ts | 21 ++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 14f3d4d4cbc7..81574b039224 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -1196,6 +1196,40 @@ describe("relay request tracing", () => { }), ); + it.effect("records the Cloudflare edge a request entered through on its server span", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const source = new Request("https://relay.test/v1/environments/env-1/connect", { + method: "POST", + }); + Object.defineProperty(source, "cf", { + value: { colo: "JNB", country: "ZA", continent: "AF", asn: 37_100 }, + }); + + yield* traceRelayHttpRequestWith( + Effect.succeed(HttpServerResponse.empty({ status: 204 })), + Layer.succeed(Tracer.Tracer, tracer), + ).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(source), + ), + ); + + const server = spans.find((span) => span.kind === "server"); + expect(server?.attributes.get("relay.edge.colo")).toBe("JNB"); + expect(server?.attributes.get("relay.edge.country")).toBe("ZA"); + expect(server?.attributes.get("relay.edge.continent")).toBe("AF"); + }), + ); + it.effect("records one server span inside the worker's disabled HTTP tracer", () => Effect.gen(function* () { const spans: Array = []; diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 49f3d33814c7..8db3a4054d64 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -207,6 +207,21 @@ const relayRequestDeadline = ( const SENDER_TRACE_HEADER = /^(traceparent|tracestate|b3|x-b3-.*)$/i; +// Where the request entered Cloudflare's network. The Worker runs near its +// database, not at this edge, so these are the only way to split relay +// latency by where users are. +const annotateRelayEdgeLocation = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const cf: unknown = request.source instanceof Request ? Reflect.get(request.source, "cf") : null; + if (typeof cf !== "object" || cf === null) return; + const attributes: Record = {}; + for (const key of ["colo", "country", "continent"] as const) { + const value: unknown = Reflect.get(cf, key); + if (typeof value === "string" && value) attributes[`relay.edge.${key}`] = value; + } + yield* Effect.annotateCurrentSpan(attributes); +}); + // The method-named server span is created below; this wrapper must not add another span. export const traceRelayHttpRequest = Effect.fnUntraced(function* ( httpEffect: Effect.Effect< @@ -216,7 +231,8 @@ export const traceRelayHttpRequest = Effect.fnUntraced(function* ( >, ) { const request = yield* HttpServerRequest.HttpServerRequest; - const traced = appendRelayTraceContextResponseHeader.pipe( + const traced = annotateRelayEdgeLocation.pipe( + Effect.andThen(appendRelayTraceContextResponseHeader), Effect.andThen(relayRequestDeadline(httpEffect)), ); if (!isRelayHookPath(request.url)) { @@ -238,7 +254,8 @@ export const traceRelayHttpRequest = Effect.fnUntraced(function* ( ), }); return yield* HttpMiddleware.tracer( - appendRelayTraceContextResponseHeader.pipe( + annotateRelayEdgeLocation.pipe( + Effect.andThen(appendRelayTraceContextResponseHeader), Effect.andThen( relayRequestDeadline( httpEffect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request)), From 02a177620a60ce870993b732c8a8b8261309da7e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:31:27 -0700 Subject: [PATCH 042/458] feat(relay): show time to connected and its phases on the relay dashboard (#22083) Co-authored-by: Claude Opus 5.5 --- docs/operations/relay-observability.md | 6 ++++ infra/relay/src/monitoring.ts | 47 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md index 6a1d7579b45c..3f174e6882de 100644 --- a/docs/operations/relay-observability.md +++ b/docs/operations/relay-observability.md @@ -121,6 +121,12 @@ Monitors count HTTP status codes, not span errors. A request the relay cuts off ends as an interrupted span without an error flag, but still answers 504. `/connect` 502s are normal: offline hosts produce most of them, retried by their clients every few seconds. +The dashboard's "Connecting" charts show connections as users see them. Web, desktop, and mobile +export a root `relay.connection.attempt` span per attempt, which lasts until initial sync finishes, in +the same trace as the relay's and the host's spans. Most reconnects reuse a cached relay token and go +straight to the host, so relay latency only affects cold connects. A slow total with fast phases +usually means the client waited out an unreachable direct (LAN) route before falling back to the relay. + **Links failing / tunnel recovery failing.** Open the dashboard's "Link failures by reason" table. `provisioning: ensure-tunnel` or `configure-tunnel` with Cloudflare 429s on the "Cloudflare API calls by status" chart is the #16810 failure: the account's Cloudflare API rate limit. Ask Cloudflare to diff --git a/infra/relay/src/monitoring.ts b/infra/relay/src/monitoring.ts index 5bdc99af1ad6..f791dfc45026 100644 --- a/infra/relay/src/monitoring.ts +++ b/infra/relay/src/monitoring.ts @@ -193,6 +193,7 @@ const relayDashboardCharts = (dataset: string): ReadonlyArray => { const source = `['${dataset}']`; const linkRequests = `${source}\n| where kind == "server" and ['attributes.http.request.method'] == "POST" and ['attributes.http.route'] == ${LINK_ROUTE}`; const tunnelRequests = `${source}\n| where kind == "server" and ['attributes.http.request.method'] == "POST" and ['attributes.http.route'] in (${TUNNEL_ROUTES})`; + const clientConnections = `${source}\n| where name == "relay.connection.attempt" and error != true\n| extend surface = tostring(['resource.custom']['t3.client.surface'])\n| where isnotempty(surface)`; return [ timeSeries( "links", @@ -242,6 +243,52 @@ const relayDashboardCharts = (dataset: string): ReadonlyArray => { apl: `${source}\n| where name == "relay.environment_linker.link" and error == true\n| extend message = tostring(['status.message'])\n| summarize failures = count() by reason = case(message contains "failed during '", strcat("provisioning: ", extract("failed during '([^']+)'", 1, message)), message startswith "Managed tunnel limit reached", "managed tunnel limit reached", substring(message, 0, 80))\n| top 10 by failures`, }, }, + // Connecting, as users see it: web, desktop and mobile export their + // connection spans into this dataset, in the same traces as the relay's + // and the host's spans. An attempt lasts until initial sync finishes. + timeSeries( + "connect-time", + "Connecting: time to connected by surface (ms)", + `${clientConnections}\n| summarize p50 = percentile(duration, 50) / 1000000, p90 = percentile(duration, 90) / 1000000 by bin_auto(_time), surface`, + ), + timeSeries( + "connect-outcomes", + "Connecting: attempts by outcome", + `${source}\n| where name == "relay.connection.attempt" and isnotempty(['resource.custom']['t3.client.surface'])\n| summarize connected = countif(error != true), failed = countif(error == true) by bin_auto(_time)`, + ), + { + id: "connect-phases", + name: "Connecting: where the time goes (ms)", + type: "Table", + query: { + // Failed direct route checks stay in: waiting out an unreachable LAN + // address before falling back to the relay is the slowest step. + apl: [ + source, + `| where isnotempty(['resource.custom']['t3.client.surface'])`, + `| extend url = tostring(['attributes.url.full'])`, + `| extend phase = case(`, + ` name == "ConnectionDriver.checkRoute", "0. Direct route check, before any relay fallback",`, + ` name == "relay.connection.cloudSessionToken.resolve", "1. Clerk session token",`, + ` name == "clientRuntime.managedRelay.exchangeAccessToken", "2. Relay token exchange",`, + ` kind == "client" and url startswith "https://relay.t3.codes/v1/environments/" and url endswith "/connect", "3. Relay connect, minting a host credential",`, + ` name == "environment.authorization.descriptor", "4. Host descriptor",`, + ` name == "environment.authorization.accessToken.exchange", "5. Host token exchange",`, + ` name == "clientRuntime.connection.remote.createDpopSocketUrl", "6. Socket ticket",`, + ` name == "environment.initialSync", "7. Initial sync",`, + ` name == "relay.connection.attempt", "Total: time to connected",`, + ` "")`, + `| where phase != "" and (error != true or phase startswith "0.")`, + `| summarize calls = count(), p50_ms = round(percentile(duration, 50) / 1000000, 0), p90_ms = round(percentile(duration, 90) / 1000000, 0) by phase`, + `| order by phase asc`, + ].join("\n"), + }, + }, + timeSeries( + "relay-latency-by-continent", + "Relay token and connect latency by user continent (p50 ms)", + `${source}\n| where kind == "server" and ['attributes.http.request.method'] == "POST" and (['attributes.http.route'] == "/v1/client/dpop-token" or ['attributes.http.route'] == "/v1/environments/:environmentId/connect")\n| extend continent = tostring(['attributes.custom']['relay.edge.continent'])\n| where isnotempty(continent)\n| summarize p50 = percentile(duration, 50) / 1000000 by bin_auto(_time), continent`, + ), ]; }; From e45ddea067e8d2337fe92845c5eb6d4c02255b9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:33:15 -0700 Subject: [PATCH 043/458] fix(client-runtime): connection attempts record their outcome and failure reason (#22081) Co-authored-by: Claude Opus 5.5 --- .../src/connection/supervisor.test.ts | 26 ++++++++-- .../src/connection/supervisor.ts | 49 +++++++++++++++---- packages/client-runtime/src/rpc/session.ts | 8 +++ 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index e5556ab84730..8b51e10c0f48 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1,8 +1,10 @@ import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Random from "effect/Random"; @@ -294,7 +296,7 @@ describe("retryDelayMs", () => { }); describe("EnvironmentSupervisor", () => { - it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => + it.effect("exports each relay setup as a standalone linked trace with its outcome", () => Effect.gen(function* () { const spans: Array = []; const tracer = Tracer.make({ @@ -308,9 +310,10 @@ describe("EnvironmentSupervisor", () => { return span; }, }); + const firstFailure = transient(); const harness = yield* makeHarness({ - prepare: (attempt) => - attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + prepare: (attempt, target) => + attempt === 1 ? Effect.fail(firstFailure) : Effect.succeed(preparedFor(target)), }); const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, @@ -334,6 +337,23 @@ describe("EnvironmentSupervisor", () => { expect(attempts[0]?.traceId).not.toBe(attempts[1]?.traceId); expect(attempts[1]?.links.map((link) => link.span.spanId)).toContain(attempts[0]?.spanId); expect(yield* Ref.get(harness.releaseCount)).toBe(0); + + const [failed, connected] = attempts; + // The exporter derives the status message from the failure, so it must + // be the connection error rather than a wrapper object. + const failedExit = failed?.status._tag === "Ended" ? failed.status.exit : undefined; + expect(failedExit && Exit.isFailure(failedExit) ? Cause.squash(failedExit.cause) : null).toBe( + firstFailure, + ); + expect(Object.fromEntries(failed?.attributes ?? [])).toMatchObject({ + "connection.outcome": "failed", + "connection.failure.tag": "ConnectionTransientError", + "connection.failure.reason": "transport", + }); + expect(Object.fromEntries(connected?.attributes ?? [])).toMatchObject({ + "connection.outcome": "connected", + "connection.route": "relay", + }); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 4d13ddf9f892..c8ec9dd2bd51 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -32,6 +32,7 @@ import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; import * as ConnectionWakeups from "./wakeups.ts"; import { connectionRouteId, + connectionRouteKind, connectionRoutes, entryWithRoutes, type ReportedEndpoint, @@ -405,6 +406,34 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); }); + // Low-cardinality dimensions for time-to-connected dashboards; the error + // detail stays in the span status, never in an attribute. + const annotateAttemptOutcome = Effect.fnUntraced(function* ( + exit: Exit.Exit, + ) { + if (Exit.isSuccess(exit)) { + const leaseTarget = exit.value.prepared.target; + const route = (yield* currentRoutes).find( + (candidate) => connectionRouteId(candidate.target) === connectionRouteId(leaseTarget), + ); + return yield* Effect.annotateCurrentSpan({ + "connection.outcome": "connected", + "connection.route": connectionRouteKind( + route ?? { target: leaseTarget, profile: Option.none() }, + ), + }); + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return yield* Effect.annotateCurrentSpan("connection.outcome", "interrupted"); + } + const failure = exit.cause.reasons.find(Cause.isFailReason)?.error; + yield* Effect.annotateCurrentSpan({ + "connection.outcome": "failed", + "connection.failure.tag": failure?._tag ?? "defect", + "connection.failure.reason": failure?.reason ?? "defect", + }); + }); + const traceRelayEstablishment = ( effect: Effect.Effect< ConnectionDriver.EnvironmentConnectionLease, @@ -415,8 +444,12 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( generation: number, pendingRetry: Option.Option, ) => { + // The span must end with the ConnectionAttemptError itself: the exporter + // can only describe an Error-like failure, and a TracedAttemptFailure + // wrapper reached it as "[object Object]". The wrapper is added outside. + let attemptSpan = Option.none(); const traced = Effect.gen(function* () { - const attemptSpan = yield* Effect.currentSpan.pipe(Effect.orDie); + attemptSpan = Option.some(yield* Effect.currentSpan.pipe(Effect.orDie)); yield* annotateTarget(target); yield* Effect.annotateCurrentSpan({ "connection.attempt": attempt, @@ -426,13 +459,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( onSome: (retry) => retry.failureCount, }), }); - const lease = yield* effect.pipe( - Effect.mapError((error): TracedAttemptFailure => ({ - error, - attemptSpan: Option.some(attemptSpan), - })), - ); - return { attemptSpan: Option.some(attemptSpan), lease }; + return yield* effect.pipe(Effect.onExit(annotateAttemptOutcome)); }).pipe(Effect.withSpan("relay.connection.attempt", { root: true })); return Option.match(pendingRetry, { @@ -444,7 +471,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( "connection.retry.reason": retry.reason, }), ), - }).pipe(withRelayClientTracing); + }).pipe( + Effect.map((lease) => ({ attemptSpan, lease })), + Effect.mapError((error): TracedAttemptFailure => ({ error, attemptSpan })), + withRelayClientTracing, + ); }; const establishTracedConnection = Effect.fnUntraced(function* ( diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index d6368980ac83..2bf48cdde052 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -214,6 +214,14 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( const protocolContext = yield* Layer.build(layerProtocol).pipe( Effect.withSpan("environment.websocket.connect"), ); + // Building the protocol only starts the socket; this span times the + // handshake up to the first open, without making `connect` wait for it. + yield* Deferred.await(connected).pipe( + Effect.raceFirst(Deferred.await(disconnected)), + Effect.withSpan("environment.websocket.open"), + Effect.ignore, + Effect.forkScoped, + ); const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); const initialConfigDeferred = yield* Deferred.make(); const serverConfigExit = yield* Deferred.make(); From 0ea5e5a53eadc4b57d16533333014e06abd56e8c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:36:22 -0700 Subject: [PATCH 044/458] perf(github): revalidate pull request stack reads with ETags (#22078) Co-authored-by: Claude Opus 5.5 --- .../src/server/GitHubPullRequestApi.test.ts | 40 ++++++++++++ .../src/server/GitHubPullRequestApi.ts | 63 ++++++++++++++++--- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 705be8fb556b..1b8d98ac4dc9 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -1119,6 +1119,46 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("revalidates a polled stack, per credential, so an unchanged one costs no quota", () => + Effect.gen(function* () { + const listing = encodeJson([ + { + number: 4, + url: "https://api.github.com/repos/acme/poll/stacks/4", + base: { ref: "main" }, + pull_requests: [ + { number: 9, head: { ref: "feat/nine" }, state: "open", merged_at: null }, + ], + }, + ]); + const read = Effect.flatMap(GitHubPullRequestApi.GitHubPullRequestApi, (cli) => + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/poll", + host: "github.com", + number: 9, + }), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listing, false, false, { etag: 'W/"v1"' })), + ); + const first = yield* read; + mockedExecute.mockReturnValueOnce(Effect.succeed(output("", false, false, {}, 304))); + const second = yield* read; + mockedCredential.mockImplementationOnce(() => + Effect.succeed({ token: Redacted.make("other"), fingerprint: "github.com:other" }), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(listing))); + yield* read; + + assert.deepStrictEqual(second, first); + expect(callAt(0)).not.toHaveProperty("ifNoneMatch"); + expect(callAt(1)).toMatchObject({ ifNoneMatch: 'W/"v1"' }); + // Another account never revalidates the first account's answer. + expect(callAt(2)).not.toHaveProperty("ifNoneMatch"); + }), + ); + it.effect("fetches layer titles only when the caller asks for stack details", () => Effect.gen(function* () { const minimal = { diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 4eb3aec349cb..937601d9461e 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1,6 +1,7 @@ import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -874,6 +875,14 @@ const PullRequestGraphQLGroup = GraphQLGroup.merge( /** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); +interface RestReadInput
    { + readonly cwd: string; + readonly host: string; + readonly operation: string; + readonly path: string; + readonly decode: (raw: string) => Result.Result; +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; @@ -1133,13 +1142,7 @@ export const make = Effect.gen(function* () { }; /** A REST read whose JSON answer is decoded the same way. */ - const restRead = (input: { - readonly cwd: string; - readonly host: string; - readonly operation: string; - readonly path: string; - readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => + const restRead = (input: RestReadInput): Effect.Effect => api .rest({ host: input.host, operation: input.operation, path: input.path }) .pipe( @@ -1148,6 +1151,49 @@ export const make = Effect.gen(function* () { ), ); + // The last ETag and body of each polled REST read, so an unchanged answer comes back as a + // 304, which GitHub does not charge. Keyed by the credential the request is pinned to, so a + // validator never revalidates another account's body. A 304 writes the entry back, so one + // that is still polled outlives its time to live. + const validators = yield* Cache.make({ + capacity: 512, + timeToLive: "1 hour", + lookup: (_key: string) => + Effect.succeed(Option.none<{ readonly etag: string; readonly body: string }>()), + }); + const revalidatedRestRead = Effect.fn("GitHubPullRequestApi.revalidatedRestRead")(function* ( + input: RestReadInput, + ) { + const host = GitHubRequestPolicy.normalizeHost(input.host); + const { token, fingerprint } = yield* api.credential(host); + const key = `${fingerprint}\0${host}\0${input.path}`; + const previous = yield* Cache.get(validators, key); + const response = yield* api + .rest({ + host, + operation: input.operation, + path: input.path, + ...(Option.isSome(previous) ? { ifNoneMatch: previous.value.etag } : {}), + }) + .pipe( + Effect.provideService(GitHubRequestPolicy.PinnedGitHubCredential, { + host, + token, + credentialFingerprint: fingerprint, + }), + ); + if (response.status === 304 && Option.isSome(previous)) { + yield* Cache.set(validators, key, previous); + return yield* decodeWith(input.cwd, input.operation, input.decode, previous.value.body); + } + const decoded = yield* decodeWith(input.cwd, input.operation, input.decode, response.body); + const etag = response.headers["etag"]?.trim(); + yield* etag + ? Cache.set(validators, key, Option.some({ etag, body: response.body })) + : Cache.invalidate(validators, key); + return decoded; + }); + const diffFilesPage = (input: { readonly cwd: string; readonly repository: string; @@ -1915,7 +1961,8 @@ export const make = Effect.gen(function* () { getPullRequestStack: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return restRead({ + // Polled by the pull request sync for every linked pull request, so revalidate it. + return revalidatedRestRead({ cwd: input.cwd, host: input.host, operation: "getPullRequestStack", From 8dec2ffef970979613f63f6b68cf3219729ef895 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:36:22 -0700 Subject: [PATCH 045/458] perf(server): read quiet pull requests every five minutes (#22073) Co-authored-by: Claude Opus 5.5 --- .../PullRequestSyncReactor.test.ts | 42 +++++++++++++++++-- .../PullRequestSyncReactor.ts | 17 +++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 9418de49d9fa..364ff7d09ba8 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -643,6 +643,7 @@ describe("PullRequestSyncReactor", () => { yield* TestClock.setTime(Date.parse(NOW)); const fixture = yield* makeHarness({ snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), + summary: (input) => Effect.succeed(makeSummary(input, { updatedAt: NOW })), }); yield* Effect.gen(function* () { @@ -855,6 +856,39 @@ describe("PullRequestSyncReactor", () => { ), ); + it.effect("reads a quiet open pull request every five minutes until it changes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const updatedAt = yield* Ref.make("2026-08-27T00:00:00.000Z"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("quiet", { pullRequests: [makeLink(5)] })]), + summary: (input) => + Ref.get(updatedAt).pipe(Effect.map((updatedAt) => makeSummary(input, { updatedAt }))), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const applySyncs = Effect.flatMap(Ref.get(fixture.syncCommands), (commands) => + Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)), + ); + yield* applySyncs; + const reads = Ref.get(fixture.summaryCalls).pipe(Effect.map((calls) => calls.length)); + assert.strictEqual(yield* reads, 1); + for (let index = 0; index < 4; index += 1) yield* sweepAgain(fixture, reactor); + assert.strictEqual(yield* reads, 1); + + // Someone comments: the five-minute read sees it, and the next sweep reads it again. + yield* Ref.set(updatedAt, "2026-08-28T12:04:00.000Z"); + yield* sweepAgain(fixture, reactor); + assert.strictEqual(yield* reads, 2); + yield* applySyncs; + yield* sweepAgain(fixture, reactor); + assert.strictEqual(yield* reads, 3); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("preserves a refresh requested while an older host read is in flight", () => Effect.scoped( Effect.gen(function* () { @@ -1116,13 +1150,15 @@ describe("PullRequestSyncReactor", () => { yield* TestClock.setTime(Date.parse(NOW)); const fixture = yield* makeHarness({ snapshot: makeSnapshot([ - makeThread("first", { pullRequests: [makeLink(7, { state: "open" })] }), - makeThread("second", { pullRequests: [makeLink(8, { state: "open" })] }), + makeThread("first", { pullRequests: [makeLink(7, { state: "open", updatedAt: NOW })] }), + makeThread("second", { + pullRequests: [makeLink(8, { state: "open", updatedAt: NOW })], + }), makeThread("third", { pullRequests: [ makeLink( 9, - { state: "open" }, + { state: "open", updatedAt: NOW }, { host: "forge.example", url: "https://forge.example/owner/repository/pulls/9" }, ), ], diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 398b788c03b2..2f2e982dd3fb 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -37,6 +37,11 @@ import * as ProjectionStore from "./ProjectionStore.ts"; 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. +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 isPullRequestProviderError = Schema.is(PullRequestProviderError); @@ -177,9 +182,17 @@ export const make = Effect.gen(function* () { ) { return false; } - if (active.some((entry) => entry.link.snapshot?.state === "open")) return true; const last = lastSyncedAt.get(key); - return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS; + const sinceLastMs = last === undefined ? Number.POSITIVE_INFINITY : nowMs - last; + const open = active.filter((entry) => entry.link.snapshot?.state === "open"); + if (open.length > 0) { + const quiet = open.every((entry) => { + const updatedAt = entry.link.snapshot?.updatedAt; + return updatedAt != null && nowMs - Date.parse(updatedAt) >= QUIET_AFTER_MS; + }); + return !quiet || sinceLastMs >= QUIET_SYNC_INTERVAL_MS; + } + return sinceLastMs >= SLOW_SYNC_INTERVAL_MS; }; const logSkipped = From c2fb438b38f5beb970dca6ced542b9f2a7f85f34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:38:03 -0700 Subject: [PATCH 046/458] fix(server): keep corrupt plan JSON in the typed read error (#21969) --- .../orchestration-v2/ProjectionStore.test.ts | 25 +++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 6 +++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 8f416f156f81..64635a75d9d8 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -11,6 +11,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ThreadShellSnapshot, NodeId, + PlanId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -997,6 +998,30 @@ it.effect("memory shell snapshots scope to one project", () => ); it.layer(layerTest)("ProjectionStoreV2", (it) => { + it.effect("returns a typed read error for malformed stored plan JSON", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const threadId = yield* addRolledBackRecoveryCandidate("corrupt-plan"); + const planId = PlanId.make("plan:corrupt"); + yield* sql`INSERT INTO orchestration_v2_projection_plans + (plan_id, thread_id, node_id, kind, status, payload_json) + VALUES (${planId}, ${threadId}, 'node:corrupt-plan', 'proposed_plan', 'active', '{broken')`; + + const failure = yield* store + .getPlan(threadId, planId) + .pipe( + Effect.flip, + Effect.ensuring( + Effect.orDie( + sql`DELETE FROM orchestration_v2_projection_plans WHERE plan_id = ${planId}`, + ), + ), + ); + assert.strictEqual(failure._tag, "ProjectionStoreReadError"); + }), + ); + it.effect( "SQL recovery selects unfinished runless native subagents", () => runlessNativeSubagentNeedsRecovery, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index f29472aaad8f..71251df71766 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1148,6 +1148,7 @@ const encodeMessagePayload = Schema.encodeEffect( const encodePlanPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2PlanArtifactSchema), ); +const decodePlanPayload = (json: string) => decodePlanJson(json); const encodeTurnItemPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2TurnItemJsonSchema), ); @@ -1194,8 +1195,9 @@ const decodeRuntimeRequestPayload = Schema.decodeUnknownEffect( const decodeMessagePayload = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2ConversationMessageJsonSchema), ); -const decodePlanArtifact = Schema.decodeUnknownEffect(OrchestrationV2PlanArtifactSchema); -const decodePlanPayload = (json: string) => decodePlanArtifact(parseEncodedPayload(json)); +const decodePlanJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2PlanArtifactSchema), +); const decodeTurnItemPayload = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2TurnItemJsonSchema), ); From 2edeeda61677c059a87ef6eadafa155acb8c6b26 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 21:44:23 -0500 Subject: [PATCH 047/458] fix(server): delete thread-bound automations with their thread (#22070) --- ...OrchestratorMcpToolkit.integration.test.ts | 2 + .../src/orchestration-v2/EffectOutbox.ts | 4 + .../src/orchestration-v2/EffectWorker.ts | 11 ++ .../ResourceCleanupService.ts | 15 +- .../orchestration-v2/ThreadDeletion.test.ts | 1 + .../src/orchestration-v2/ThreadDeletion.ts | 6 + .../src/orchestration-v2/runtimeLayer.test.ts | 157 ++++++++++++++++++ .../src/orchestration-v2/runtimeLayer.ts | 7 +- .../project/ProjectService.deletion.test.ts | 9 +- .../ScheduledTaskService.test.ts | 77 ++++++++- .../scheduledTasks/ScheduledTaskService.ts | 63 +++++-- apps/server/src/server.ts | 2 - docs/user/project-settings.md | 3 + 13 files changed, 337 insertions(+), 20 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index eb639653bfb7..07fe6735b72c 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -520,6 +520,7 @@ const layerUnusedScheduledTaskStub = Layer.succeed( upsert: () => Effect.die("ScheduledTaskService.upsert is unused in this test"), setEnabled: () => Effect.die("ScheduledTaskService.setEnabled is unused in this test"), delete: () => Effect.die("ScheduledTaskService.delete is unused in this test"), + deleteForThread: () => Effect.die("unused in this test"), runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), rotateWebhookToken: () => Effect.die("unused in this test"), listWebhookDeliveries: () => Effect.die("unused in this test"), @@ -679,6 +680,7 @@ describe("orchestrator MCP toolkit", () => { Ref.update(scheduledStore, (all) => all.filter((candidate) => candidate.id !== input.id), ).pipe(Effect.as({ id: input.id })), + deleteForThread: () => Effect.die("unused in this test"), runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), rotateWebhookToken: () => Effect.die("unused in this test"), listWebhookDeliveries: () => Effect.die("unused in this test"), diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 18c2283fb3dc..05be41e57285 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -93,6 +93,9 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ runId: RunId, scopeId: CheckpointScopeId, }), + Schema.Struct({ + type: Schema.Literal("scheduled-tasks.cleanup"), + }), Schema.Struct({ type: Schema.Literal("terminal.cleanup"), }), @@ -122,6 +125,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ "provider-session.detach", "provider-thread.rollback", "checkpoint.capture", + "scheduled-tasks.cleanup", "terminal.cleanup", "preview.cleanup", "attachment.cleanup", diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index d47635a09104..bb7b76a0ab2c 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -462,6 +462,17 @@ export const layerExecutor: Layer.Layer< }), ), ); + case "scheduled-tasks.cleanup": + return resourceCleanup.cleanupScheduledTasks(effect.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); case "terminal.cleanup": return resourceCleanup.cleanupTerminals(effect.threadId).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index 581132003122..87f024175a13 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -8,12 +8,13 @@ import * as Schema from "effect/Schema"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as TerminalManager from "../terminal/Manager.ts"; export class ResourceCleanupError extends Schema.TaggedError()( "ResourceCleanupError", { - operation: Schema.Literals(["terminal", "preview", "attachment"]), + operation: Schema.Literals(["terminal", "preview", "attachment", "scheduled-tasks"]), threadId: Schema.optional(Schema.String), attachmentId: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -21,6 +22,7 @@ export class ResourceCleanupError extends Schema.TaggedError Effect.Effect; readonly cleanupTerminals: (threadId: string) => Effect.Effect; readonly cleanupPreviews: (threadId: string) => Effect.Effect; readonly cleanupAttachments: ( @@ -28,6 +30,7 @@ export class ResourceCleanupService extends Context.Reference<{ ) => Effect.Effect; }>("t3/orchestration-v2/ResourceCleanupService", { defaultValue: () => ({ + cleanupScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, cleanupAttachments: () => Effect.void, @@ -41,7 +44,17 @@ export const layer = Layer.effect( const previews = yield* PreviewManager.PreviewManager; const fileSystem = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; + const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; return { + cleanupScheduledTasks: (threadId: ThreadId) => + scheduledTasks + .deleteForThread(threadId) + .pipe( + Effect.mapError( + (cause) => + new ResourceCleanupError({ operation: "scheduled-tasks", threadId, cause }), + ), + ), cleanupTerminals: (threadId: string) => terminals .close({ threadId, deleteHistory: true }) diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts index da24f247f902..ef7c23bf7512 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts @@ -290,6 +290,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio detail: "Thread deleted.", revokeMcpCredential: true, }, + { type: "scheduled-tasks.cleanup" }, { type: "terminal.cleanup" }, { type: "preview.cleanup" }, { type: "attachment.cleanup", attachmentIds: ["shared_file"] }, diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index 13b242175b5f..67a27fcf7696 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -208,6 +208,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") }, }); } + effects.push({ + id: `effect:${command.commandId}:scheduled-tasks.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "scheduled-tasks.cleanup" }, + }); effects.push({ id: `effect:${command.commandId}:terminal.cleanup`, commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 3132ba6a0e9d..59ccca86a164 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -27,6 +27,7 @@ import { ProviderThreadId, ProviderTurnId, RunId, + ScheduledTaskId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -75,6 +76,14 @@ import * as RuntimeLayer from "./runtimeLayer.ts"; import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as PreviewManager from "../preview/Manager.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ResourceCleanupService from "./ResourceCleanupService.ts"; +import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -295,6 +304,30 @@ const layerLegacyImportTest = RuntimeLayer.layer.pipe( Layer.provide(layerPlatformTest), ); +const layerScheduledTasksTest = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Scheduler.layer, + ), + ), +); +const layerResourceCleanupTest = ResourceCleanupService.layer.pipe( + Layer.provideMerge(layerScheduledTasksTest), + Layer.provide( + Layer.mergeAll( + Layer.mock(PreviewManager.PreviewManager)({ close: () => Effect.void }), + Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void }), + ), + ), +); + const layerProjectDeletionTest = Layer.mergeAll( RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)), RuntimeLayer.layerProjectService, @@ -323,6 +356,7 @@ const layerProjectDeletionTest = Layer.mergeAll( normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), }), ), + Layer.provideMerge(layerResourceCleanupTest), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(McpProviderSessions.layer), Layer.provide(SqlitePersistence.layerMemory), @@ -335,6 +369,129 @@ const layerProjectDeletionTest = Layer.mergeAll( ); it.layer(layerProjectDeletionTest)("project deletion during thread commands", (it) => { + it.effect.each(["thread", "project"] as const)( + "deleting a %s removes its thread-bound automations through the effect worker", + (deletion) => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const tasks = yield* ScheduledTaskService.ScheduledTaskService; + const projectId = ProjectId.make(`automation-delete-project:${deletion}`); + const threadId = ThreadId.make(`automation-delete-thread:${deletion}`); + const otherThreadId = ThreadId.make(`automation-surviving-thread:${deletion}`); + yield* projects.create({ + commandId: CommandId.make(`automation-create-project:${deletion}`), + projectId, + title: "Automation lifecycle", + workspaceRoot: `/work/automation-lifecycle/${deletion}`, + }); + for (const id of [threadId, otherThreadId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`automation-create:${id}`), + createdBy: "user", + creationSource: "web", + threadId: id, + projectId: id === threadId ? projectId : ProjectId.make("automation-other-project"), + title: "Automation target", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + } + const save = (name: string, target: ThreadId | null, webhook = false) => + tasks.upsert({ + id: ScheduledTaskId.make(`automation:${deletion}:${name}`), + title: name, + prompt: "Check orchestration progress", + enabled: !webhook, + schedule: webhook ? { type: "webhook" } : { type: "interval", everyMs: 60_000 }, + projectId, + threadId: target, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + const { task: bound } = yield* save("bound", threadId); + const { task: webhook } = yield* save("webhook", threadId, true); + const { task: independent } = yield* save("independent", null); + yield* save("detached", threadId); + const { task: detached } = yield* save("detached", null); + yield* save("moved", threadId); + const { task: moved } = yield* save("moved", otherThreadId); + const { task: other } = yield* save("other", otherThreadId); + yield* tasks.triggerWebhook({ + hookId: webhook.id, + token: webhook.webhook!.path.split("/").at(-1)!, + method: "POST", + path: webhook.webhook!.path, + query: "", + headers: {}, + body: new Uint8Array(), + bodyText: "", + }); + assert.lengthOf((yield* tasks.listWebhookDeliveries({ id: webhook.id })).deliveries, 1); + + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make(`automation-archive:${deletion}`), + threadId, + }); + yield* worker.drain(); + assert.include( + (yield* tasks.list()).tasks.map((task) => task.id), + bound.id, + ); + + if (deletion === "thread") { + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`automation-delete:${deletion}`), + threadId, + }); + } else { + yield* projects.delete({ + commandId: CommandId.make(`automation-delete:${deletion}`), + projectId, + force: true, + }); + } + yield* worker.drain(); + const remainingIds = (yield* tasks.list()).tasks.map((task) => task.id); + assert.notInclude(remainingIds, bound.id); + assert.notInclude(remainingIds, webhook.id); + assert.includeMembers(remainingIds, [independent.id, detached.id, moved.id, other.id]); + assert.lengthOf((yield* tasks.listWebhookDeliveries({ id: webhook.id })).deliveries, 0); + const rejected = yield* tasks.runNow({ id: bound.id }).pipe(Effect.flip); + assert.equal(rejected.message, "Schedule task not found."); + assert.equal( + (yield* tasks.triggerWebhook({ + hookId: webhook.id, + token: webhook.webhook!.path.split("/").at(-1)!, + method: "POST", + path: webhook.webhook!.path, + query: "", + headers: {}, + body: new Uint8Array(), + bodyText: "", + }))._tag, + "not_found", + ); + // Retried cleanup is harmless and cannot affect unrelated tasks. + yield* tasks.deleteForThread(threadId); + assert.sameMembers( + (yield* tasks.list()).tasks.map((task) => task.id), + remainingIds, + ); + }), + ); + it.effect("waits for an in-flight thread update before planning deletion", () => Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 7970772d8ed0..fef1dd484f4c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -44,6 +44,7 @@ import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; import * as ProviderTurnStartService from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; +import * as ResourceCleanupService from "./ResourceCleanupService.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; @@ -372,4 +373,8 @@ export const layerProduction = Layer.mergeAll( layerProviderContinuationWorkerProvided, layerAgentSessionImporterProvided, EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)), -).pipe(Layer.provide(Scheduler.layer), Layer.provideMerge(layerEventInfrastructure)); +).pipe( + Layer.provide(ResourceCleanupService.layer.pipe(Layer.provide(layerScheduledTaskProvided))), + Layer.provide(Scheduler.layer), + Layer.provideMerge(layerEventInfrastructure), +); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index a35e58224baa..0e9f01dc653c 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -203,6 +203,7 @@ it.effect("retries a partial project deletion without repeating child events or partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]), [ [firstThreadId, "preview.cleanup"], + [firstThreadId, "scheduled-tasks.cleanup"], [firstThreadId, "terminal.cleanup"], ], ); @@ -226,7 +227,7 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual(finalEvents[0], partialEvents[0]); assert.equal(finalEvents[2]?.command_id, commandId); const finalCleanup = yield* readCleanup; - assert.lengthOf(finalCleanup, 4); + assert.lengthOf(finalCleanup, 6); assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === firstThreadId), partialCleanup, @@ -242,6 +243,12 @@ it.effect("retries a partial project deletion without repeating child events or command_id: expectedCommandId, effect_type: "preview.cleanup", }, + { + effect_id: `effect:${expectedCommandId}:scheduled-tasks.cleanup`, + thread_id: threadId, + command_id: expectedCommandId, + effect_type: "scheduled-tasks.cleanup", + }, { effect_id: `effect:${expectedCommandId}:terminal.cleanup`, thread_id: threadId, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 82286f2340fa..466af14e7f22 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -3,13 +3,21 @@ import * as NodeUtil from "node:util"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it } from "@effect/vitest"; -import { ScheduledTaskError } from "@t3tools/contracts"; +import { + ProjectId, + ProviderInstanceId, + ScheduledTaskError, + ScheduledTaskId, + ThreadId, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; import * as Deferred from "effect/Deferred"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -330,3 +338,70 @@ it.effect( ); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("thread cleanup notifies subscribers and a finishing run cannot recreate the task", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const subscribed = yield* Deferred.make(); + const running = yield* Deferred.make(); + const removed = yield* Deferred.make(); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.die("test dispatch failure")), + ), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const threadId = ThreadId.make("thread:deleted-during-run"); + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:deleted-during-run"), + title: "Check orchestration", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + threadId, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + yield* service.subscribeList().pipe( + Stream.runForEach(({ tasks }) => { + const current = tasks.find((candidate) => candidate.id === task.id); + if (current === undefined) return Deferred.succeed(removed, undefined); + return current.lastRunStatus === "running" + ? Deferred.succeed(running, undefined) + : Deferred.succeed(subscribed, undefined); + }), + Effect.forkChild, + ); + yield* Deferred.await(subscribed); + const run = yield* service.runNow({ id: task.id }).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Deferred.await(running); + yield* service.deleteForThread(threadId); + yield* Deferred.await(removed); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(run); + assert.lengthOf((yield* service.list()).tasks, 0); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 99e215be7dcb..f00e19367830 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -206,6 +206,8 @@ export class ScheduledTaskService extends Context.Service< readonly delete: ( input: ScheduledTaskDeleteInput, ) => Effect.Effect; + /** Deletes only tasks that still target this thread, including their webhook deliveries. */ + readonly deleteForThread: (threadId: ThreadId) => Effect.Effect; readonly runNow: ( input: ScheduledTaskRunNowInput, ) => Effect.Effect; @@ -1087,26 +1089,58 @@ export const layer = Layer.effect( }; }); + const forgetWebhookTask = (id: ScheduledTaskId) => + Effect.all( + [ + Ref.update(webhookRateWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookPermits, (permits) => { + const next = new Map(permits); + next.delete(id); + return next; + }), + ], + { discard: true }, + ); + const deleteTask: ScheduledTaskService["Service"]["delete"] = (input) => deleteRow(input.id).pipe( - Effect.andThen( - Effect.all([ - Ref.update(webhookRateWindows, (windows) => { - const next = new Map(windows); - next.delete(input.id); - return next; - }), - Ref.update(webhookPermits, (permits) => { - const next = new Map(permits); - next.delete(input.id); - return next; - }), - ]), - ), + Effect.andThen(forgetWebhookTask(input.id)), Effect.andThen(notifyChanged), Effect.as({ id: input.id }), ); + const deleteForThread = Effect.fn("ScheduledTaskService.deleteForThread")(function* ( + threadId: ThreadId, + ) { + // Check the current binding in the transaction: an automation may have + // been unbound or moved since thread deletion queued this cleanup. + const removed = yield* sql + .withTransaction( + Effect.gen(function* () { + yield* sql` + DELETE FROM scheduled_task_webhook_deliveries + WHERE task_id IN (SELECT task_id FROM scheduled_tasks WHERE thread_id = ${threadId}) + `; + return yield* sql<{ task_id: ScheduledTaskId }>` + DELETE FROM scheduled_tasks WHERE thread_id = ${threadId} RETURNING task_id + `; + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not delete thread's schedule tasks.", { cause }), + ), + ); + yield* Effect.forEach(removed, ({ task_id }) => forgetWebhookTask(task_id), { + discard: true, + }); + if (removed.length > 0) yield* notifyChanged; + }); + const runNow: ScheduledTaskService["Service"]["runNow"] = Effect.fn( "ScheduledTaskService.runNow", )(function* (input: ScheduledTaskRunNowInput) { @@ -1613,6 +1647,7 @@ export const layer = Layer.effect( upsert, setEnabled, delete: deleteTask, + deleteForThread, runNow, rotateWebhookToken, listWebhookDeliveries, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f8415f3d8c98..ed424a726a76 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -174,7 +174,6 @@ import * as UsageService from "./usage/UsageService.ts"; import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; -import * as ResourceCleanupService from "./orchestration-v2/ResourceCleanupService.ts"; import * as ThreadSettlementService from "./orchestration-v2/ThreadSettlementService.ts"; import * as ThreadPullRequestService from "./orchestration-v2/ThreadPullRequestService.ts"; import * as RunFinalizationService from "./orchestration-v2/RunFinalizationService.ts"; @@ -496,7 +495,6 @@ const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(ProviderEventIngestor.layerAnalytics), Layer.provide(layerCheckpointStore), Layer.provide(layerGitWorkflow), - Layer.provide(ResourceCleanupService.layer), Layer.provide( RunFinalizationService.layerObserver.pipe( Layer.provide(ProjectionStoreV2.layer), diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 924b04d6944e..bb89de6717e8 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -68,6 +68,9 @@ list by environment or project. Each task runs on the environment you choose, using its project, model, and workspace settings. Fixed-time schedules use that environment's time zone, which may differ from your phone's. +Deleting a thread also deletes tasks that post into that thread. Tasks that start +a new thread on each run survive deletion of their previous threads. + You can edit, pause, resume, run immediately, or delete a task from the list. Webhook tasks only run when their URL is called, so they can't be run immediately. From b912bd2da0a311e7b0934163b399c07e6c53ebb9 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 21:45:43 -0500 Subject: [PATCH 048/458] fix(web): measure draft project names in the DOM (#22071) --- .../chat/DraftProjectName.browser.test.tsx | 103 ++++++++++++++++++ .../src/components/chat/DraftProjectName.tsx | 26 +++-- 2 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/chat/DraftProjectName.browser.test.tsx diff --git a/apps/web/src/components/chat/DraftProjectName.browser.test.tsx b/apps/web/src/components/chat/DraftProjectName.browser.test.tsx new file mode 100644 index 000000000000..fe8a2f5946e3 --- /dev/null +++ b/apps/web/src/components/chat/DraftProjectName.browser.test.tsx @@ -0,0 +1,103 @@ +import "~/index.css"; + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { InlineButton } from "../ui/button"; +import { DraftProjectName } from "./DraftProjectName"; + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + host = document.body.appendChild(document.createElement("div")); + host.style.width = "700px"; + host.style.zoom = "1.31"; + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function render(value: string, placement: "headline" | "row") { + await act(async () => { + root.render( + placement === "headline" ? ( +

    + What should we build in{" "} + + + + + ? + +

    + ) : ( +
    + + + + +
    + ), + ); + }); +} + +function label() { + return host.querySelector("span[aria-label]")!; +} + +it.each(["headline", "row"] as const)( + "keeps fitting names in the %s even when canvas metrics disagree with layout", + async (placement) => { + host.style.zoom = "1"; + const measureText = CanvasRenderingContext2D.prototype.measureText; + vi.spyOn(CanvasRenderingContext2D.prototype, "measureText").mockImplementation(function ( + this: CanvasRenderingContext2D, + text, + ) { + const metrics = measureText.call(this, text); + Object.defineProperty(metrics, "width", { value: metrics.width + 8 }); + return metrics; + }); + + for (const value of ["t3code", "mozart_v2", "No project", "Add project"]) { + await render(value, placement); + expect(label().textContent).toBe(value); + } + }, +); + +it.each(["headline", "row"] as const)( + "fits long names in the %s and restores them when the available space grows", + async (placement) => { + const value = "ExampleCompany-CustomerPortal-App"; + await render(value, placement); + expect(label().textContent).toBe(value); + + await act(async () => { + host.style.width = "180px"; + await expect.poll(() => label().textContent).toContain("…"); + }); + const shortened = label().textContent!; + expect(shortened.startsWith("Ex")).toBe(true); + expect(shortened.endsWith("App")).toBe(true); + const text = document.createRange(); + text.selectNodeContents(label()); + expect(text.getBoundingClientRect().width).toBeLessThanOrEqual( + label().getBoundingClientRect().width, + ); + + await act(async () => { + host.style.width = "700px"; + await expect.poll(() => label().textContent).toBe(value); + }); + }, +); diff --git a/apps/web/src/components/chat/DraftProjectName.tsx b/apps/web/src/components/chat/DraftProjectName.tsx index 96fc218c6687..01b27223bc8b 100644 --- a/apps/web/src/components/chat/DraftProjectName.tsx +++ b/apps/web/src/components/chat/DraftProjectName.tsx @@ -6,23 +6,22 @@ import { truncateProjectName } from "./DraftProjectName.logic"; export function DraftProjectName({ value }: { value: string }) { const containerRef = useRef(null); + const measurementRef = useRef(null); const [displayName, setDisplayName] = useState(value); useLayoutEffect(() => { const container = containerRef.current; - const context = document.createElement("canvas").getContext("2d"); - if (!container || !context) return; + const measurement = measurementRef.current; + if (!container || !measurement) return; const update = () => { - const style = getComputedStyle(container); - context.font = style.font; - context.letterSpacing = style.letterSpacing === "normal" ? "0px" : style.letterSpacing; + // Canvas text metrics can differ from layout for the same font, weight, and zoom. + // Measure in the DOM so a name that fits never loses characters to that mismatch. setDisplayName( - truncateProjectName( - value, - container.getBoundingClientRect().width, - (text) => context.measureText(text).width, - ), + truncateProjectName(value, container.getBoundingClientRect().width, (text) => { + measurement.textContent = text; + return measurement.getBoundingClientRect().width; + }), ); }; update(); @@ -37,7 +36,7 @@ export function DraftProjectName({ value }: { value: string }) { return ( {/* Keep the full name's intrinsic width so fitting the visible text cannot shrink its budget. */} {displayName} + ); } From 639ac5ec77e65871fa1df64d64d85cc28ed42694 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 19 Oct 2026 22:47:40 -0400 Subject: [PATCH 049/458] fix(server): explain a disabled tailnet Serve instead of timing out (#10958) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../backend/tailscaleEndpointProvider.test.ts | 1 + apps/server/src/cli/pair.test.ts | 55 +++++++++++++++++++ apps/server/src/cli/pair.ts | 14 +++++ packages/tailscale/src/tailscale.test.ts | 14 +++++ packages/tailscale/src/tailscale.ts | 12 ++++ 5 files changed, 96 insertions(+) diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 1911654bdfa5..c3cdaaa317e6 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -209,6 +209,7 @@ describe("tailscale endpoint provider", () => { return { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.74.126.34"], + httpsServeEnabled: null, }; }), }); diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 207721b64a14..845f20195aa7 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -11,8 +11,11 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import * as TestConsole from "effect/testing/TestConsole"; import { Command, CliError } from "effect/cli"; +import { ChildProcessSpawner } from "effect/process"; import { cli } from "../binCli.ts"; import { @@ -318,6 +321,58 @@ describe("t3 pair", () => { assert.include(rendered, "No running T3 Code server found."); }).pipe(Effect.provide(NodeServices.layer)), ); + + // Without the capability `tailscale serve` blocks on an enablement URL, so + // pairing must stop at status instead of spawning it. + it.effect("explains a tailnet without HTTPS serve instead of running tailscale serve", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-ts-test-")); + yield* persistServerRuntimeState({ + path: NodePath.join(baseDir, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + const tailscaleCalls: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + const { args } = command as unknown as { readonly args: ReadonlyArray }; + tailscaleCalls.push(args); + const stdout = + args[0] === "status" + ? `{"Self":{"DNSName":"pair-test.invalid.","CapMap":{"funnel":null}}}` + : ""; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(args[0] === "status" ? 0 : 1)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.flip, + ), + ); + + assert.include(String(error), "Tailscale Serve is not enabled on your tailnet."); + assert.include(String(error), "https://login.tailscale.com/admin/dns"); + assert.deepEqual(tailscaleCalls, [["status", "--json"]]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); }); describe("auth scope options", () => { diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 073606bf6374..89816bf9dda8 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -90,6 +90,15 @@ export class MagicDnsNameMissingError extends Schema.TaggedError()( + "TailscaleServeNotEnabledError", + {}, +) { + override get message(): string { + return "Tailscale Serve is not enabled on your tailnet. Enable HTTPS certificates at https://login.tailscale.com/admin/dns, then run this command again."; + } +} + export class ServesOtherEnvironmentError extends Schema.TaggedError()( "ServesOtherEnvironmentError", { servePort: Schema.Number }, @@ -346,6 +355,11 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" if (status.magicDnsName === null) { return yield* new MagicDnsNameMissingError(); } + // Without the capability `tailscale serve` blocks on an enablement URL + // until it times out, which names neither the setting nor where to change it. + if (status.httpsServeEnabled === false) { + return yield* new TailscaleServeNotEnabledError(); + } const baseUrl = buildTailscaleHttpsBaseUrl({ magicDnsName: status.magicDnsName, servePort: input.servePort, diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 5e467c5539ce..da20dcd60427 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -142,10 +142,23 @@ describe("tailscale", () => { assert.deepEqual(status, { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.100.100.100"], + httpsServeEnabled: null, }); }), ); + it.effect("reads the HTTPS serve capability from the status capability map", () => + Effect.gen(function* () { + const enabled = yield* parseTailscaleStatus( + `{"Self":{"CapMap":{"https":null,"funnel":null}}}`, + ); + assert.equal(enabled.httpsServeEnabled, true); + + const disabled = yield* parseTailscaleStatus(`{"Self":{"CapMap":{"funnel":null}}}`); + assert.equal(disabled.httpsServeEnabled, false); + }), + ); + it.effect("preserves status decoding failures without exposing cause text", () => Effect.gen(function* () { const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); @@ -203,6 +216,7 @@ describe("tailscale", () => { assert.deepEqual(status, { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.90.1.2"], + httpsServeEnabled: null, }); }); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 4e8606e252a3..28664e23fa9b 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -125,6 +125,7 @@ export class TailscaleStatusParseError extends Schema.TaggedError(stream: Stream.Stream): Effect.Effect => @@ -203,9 +210,14 @@ export const parseTailscaleStatus = ( } } + const capMap = parsed.Self?.CapMap; return { magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, + httpsServeEnabled: + typeof capMap === "object" && capMap !== null && !Array.isArray(capMap) + ? Object.hasOwn(capMap, "https") + : null, }; }), ); From cd2b885b77d3df0aedf8a2cfd6ecf355e7815ea0 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:48:44 +0200 Subject: [PATCH 050/458] fix(web): Connections no longer says "Up to date" while a desktop update is pending (#14790) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../ConnectionsSettings.logic.test.ts | 60 ++++++++++++++++++- .../settings/ConnectionsSettings.logic.ts | 37 ++++++++++++ .../settings/ConnectionsSettings.tsx | 14 ++++- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 82037ae1db78..82d1fb7bb83d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,4 +1,4 @@ -import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; +import type { AdvertisedEndpoint, DesktopUpdateState, DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, @@ -6,6 +6,7 @@ import { isQrShareableEndpoint, isWslSettingsRowVisible, missingTailscaleEndpointDescription, + resolveLocalVersionStatusLabel, selectQrEndpointOption, togglePairingScopeSelection, } from "./ConnectionsSettings.logic"; @@ -54,6 +55,63 @@ const baseWslState: DesktopWslState = { preflightError: null, }; +describe("resolveLocalVersionStatusLabel", () => { + const baseState: DesktopUpdateState = { + enabled: true, + status: "idle", + channel: "latest", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; + + it("stays up to date on the web and when the desktop updater has nothing pending", () => { + expect(resolveLocalVersionStatusLabel(null)).toBe("Up to date"); + expect(resolveLocalVersionStatusLabel(baseState)).toBe("Up to date"); + }); + + it.each<[string, Partial, string]>([ + ["available", { status: "available", availableVersion: "1.2.0" }, "Update 1.2.0 available"], + [ + "downloading", + { status: "downloading", availableVersion: "1.2.0" }, + "Downloading update 1.2.0", + ], + [ + "downloaded", + { status: "downloaded", availableVersion: "1.2.0", downloadedVersion: "1.1.0" }, + "Update 1.1.0 downloaded", + ], + [ + "a failed download that can be retried", + { status: "error", errorContext: "download", availableVersion: "1.2.0", canRetry: true }, + "Update 1.2.0 available", + ], + [ + "a failed install", + { status: "error", errorContext: "install", downloadedVersion: "1.1.0" }, + "Update 1.1.0 downloaded", + ], + [ + "a recheck that keeps a downloaded update", + { status: "checking", downloadedVersion: "1.1.0" }, + "Update 1.1.0 downloaded", + ], + ])("names the pending update for %s", (_, state, label) => { + expect(resolveLocalVersionStatusLabel({ ...baseState, ...state })).toBe(label); + }); +}); + describe("isWslSettingsRowVisible", () => { it("shows the retry row when the WSL state failed to load", () => { expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index b9b0f145dd6c..9e9f5be1b91e 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -5,8 +5,13 @@ import { AuthTerminalReadScope, type DesktopBridge, type DesktopServerExposureState, + type DesktopUpdateState, type DesktopWslState, } from "@t3tools/contracts"; +import { + getDesktopUpdateDownloadedVersion, + resolveDesktopUpdateButtonAction, +} from "../desktopUpdate.logic"; /** * Operating terminals without being able to list them leaves a client @@ -70,6 +75,38 @@ export function isWslSettingsRowVisible(input: { return state ? state.available || state.enabled || state.wslOnly : error !== null; } +/** + * Passive label for this machine's Version row once client and server versions + * match. The desktop bundles its server, so a pending app update is the only + * way it falls behind. Pending states follow the sidebar update control's + * action, including retryable failures, so the two never disagree; the sidebar + * is where to act. A recheck keeps a downloaded update while `checking`, so it + * keeps its label too. `state` is null outside the desktop app. + */ +export function resolveLocalVersionStatusLabel(state: DesktopUpdateState | null): string { + if (!state) return "Up to date"; + if (state.status === "downloading") { + return state.availableVersion + ? `Downloading update ${state.availableVersion}` + : "Downloading update"; + } + const action = resolveDesktopUpdateButtonAction(state); + if ( + action === "install" || + state.status === "downloaded" || + (state.status === "checking" && state.downloadedVersion !== null) + ) { + const version = getDesktopUpdateDownloadedVersion(state); + return version ? `Update ${version} downloaded` : "Update downloaded"; + } + if (action === "download") { + return state.availableVersion + ? `Update ${state.availableVersion} available` + : "Update available"; + } + return "Up to date"; +} + export type QrEndpointOption = { readonly id: string; /** diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b984de57f764..ea577b2f1ac5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -71,6 +71,7 @@ import { isQrShareableEndpoint, isWslSettingsRowVisible, missingTailscaleEndpointDescription, + resolveLocalVersionStatusLabel, selectQrEndpointOption, togglePairingScopeSelection, } from "./ConnectionsSettings.logic"; @@ -176,6 +177,7 @@ import { } from "~/state/desktopNetworkAccess"; import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; +import { useDesktopUpdateState } from "~/state/desktopUpdate"; import { type EnvironmentPresentation, useEnvironments, @@ -2003,6 +2005,16 @@ function CloudRemoteEnvironmentRows({ ) : null; } +// Subscribes on its own so desktop download progress does not re-render the page. +function LocalVersionStatusLabel() { + const updateState = useDesktopUpdateState(); + return ( + + {resolveLocalVersionStatusLabel(updateState)} + + ); +} + export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -3470,7 +3482,7 @@ export function ConnectionsSettings() { } /> ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( - Up to date + ) : undefined } /> From d5fb0b16f2f1289949424398886e819dfe8050c2 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 20 Oct 2026 02:48:57 +0000 Subject: [PATCH 051/458] fix(web): keep snapshots quiet while disabled (#15638) Co-authored-by: Julius Marminge --- ...ts => SnapShotCoordinator.browser.test.ts} | 369 ++++++++++++++++-- .../desktop/SnapShotCoordinator.tsx | 41 +- 2 files changed, 374 insertions(+), 36 deletions(-) rename apps/web/src/components/desktop/{SnapShotCoordinator.test.ts => SnapShotCoordinator.browser.test.ts} (50%) diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts similarity index 50% rename from apps/web/src/components/desktop/SnapShotCoordinator.test.ts rename to apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts index eb0a6fbb1352..4b2287b8707a 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts @@ -1,14 +1,23 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { + type ClientSettings, type DesktopPendingSnapShot, + type DesktopSnapShotEvent, + DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId, ThreadId, } from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import type { DesktopSnapShotBridge } from "../../lib/desktopSnapShot"; +import * as settings from "../../hooks/useSettings"; +import * as newThread from "../../hooks/useHandleNewThread"; +import * as snapShotSound from "../../lib/snapShotSound"; +import { toastManager } from "../ui/toast"; import { beginSnapShotAnimationWhenReady, deliverSnapShot, @@ -16,6 +25,7 @@ import { resolveExistingSnapShotTarget, resolveSnapShotTargetOnce, resolveSnapShotDeliveryTarget, + SnapShotCoordinator, } from "./SnapShotCoordinator"; import { beginSnapShotAnimation, @@ -25,6 +35,10 @@ import { scheduleSnapShotAnimationDestination, } from "../../lib/snapShotAnimation"; +vi.mock(import("../../hooks/useSettings"), { spy: true }); +vi.mock(import("../../hooks/useHandleNewThread"), { spy: true }); +vi.mock(import("../../lib/snapShotSound"), { spy: true }); + const storage = vi.hoisted(() => { const values = new Map(); const storage = { @@ -60,6 +74,7 @@ afterEach(() => { dismissAllSnapShotAnimations(); vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); describe("window capture failures", () => { @@ -68,19 +83,16 @@ describe("window capture failures", () => { const pendingStarts = new Set(); const soundedIds = new Set(["older", "newer"]); const dismissSnapShotAnimation = vi.fn(async () => undefined); - vi.stubGlobal("window", { - localStorage: storage, - desktopBridge: { - requestSnapShotPermissions: vi.fn(), - getSnapShotState: vi.fn(), - checkSnapShotShortcut: vi.fn(), - setSnapShotShortcutSuppressed: vi.fn(), - listPendingSnapShots: vi.fn(), - readSnapShot: vi.fn(), - acknowledgeSnapShot: vi.fn(), - onSnapShotEvent: vi.fn(() => () => undefined), - dismissSnapShotAnimation, - }, + vi.stubGlobal("desktopBridge", { + requestSnapShotPermissions: vi.fn(), + getSnapShotState: vi.fn(), + checkSnapShotShortcut: vi.fn(), + setSnapShotShortcutSuppressed: vi.fn(), + listPendingSnapShots: vi.fn(), + readSnapShot: vi.fn(), + acknowledgeSnapShot: vi.fn(), + onSnapShotEvent: vi.fn(() => () => undefined), + dismissSnapShotAnimation, }); await beginSnapShotAnimationWhenReady("older", Promise.resolve(target), pendingStarts); await beginSnapShotAnimationWhenReady("newer", Promise.resolve(target), pendingStarts); @@ -133,6 +145,305 @@ describe("window capture failures", () => { }); }); +describe("pending snapshot processing", () => { + const capture = { + id: "12345678-1234-1234-1234-123456789abc", + name: "window.png", + mimeType: "image/png" as const, + sizeBytes: 3, + dataUrl: "data:image/png;base64,AQID", + source: { + kind: "snap-shot" as const, + capturedAt: "2026-09-01T00:00:00.000Z", + appName: "Editor", + windowTitle: "main.ts", + }, + }; + const target = scopeThreadRef(environmentId, ThreadId.make("enabled-thread")); + let root: Root; + let container: HTMLDivElement; + + function setup(routeThreadRef: typeof target | null = target) { + let currentSettings = { + ...DEFAULT_CLIENT_SETTINGS, + snapShotEnabled: true, + snapShotPlaySound: true, + snapShotAnimations: false, + }; + const listeners = new Set<(event: DesktopSnapShotEvent) => void>(); + let pending = [capture]; + const bridge = { + requestSnapShotPermissions: vi.fn(), + getSnapShotState: vi.fn(), + checkSnapShotShortcut: vi.fn(), + setSnapShotShortcutSuppressed: vi.fn(), + onSnapShotEvent: vi.fn((listener: (event: DesktopSnapShotEvent) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + listPendingSnapShots: vi.fn(async () => pending), + readSnapShot: vi.fn(async () => capture), + acknowledgeSnapShot: vi.fn(async (id: string) => { + pending = pending.filter((item) => item.id !== id); + }), + }; + vi.spyOn(settings, "getClientSettings").mockImplementation(() => currentSettings); + vi.spyOn(settings, "useClientSettings").mockImplementation( + (selector?: (value: ClientSettings) => T) => + selector ? selector(currentSettings) : (currentSettings as T), + ); + const route = { + activeDraftThread: null, + activeThread: null, + defaultProjectRef: routeThreadRef ? projectRef : null, + handleNewThread: vi.fn["handleNewThread"]>(), + routeDraftId: null, + routeThreadRef, + }; + vi.spyOn(newThread, "useHandleNewThread").mockImplementation(() => ({ ...route })); + const sound = vi.spyOn(snapShotSound, "playSnapShotSound").mockImplementation(() => undefined); + const toast = vi.spyOn(toastManager, "add").mockReturnValue("toast-id"); + vi.stubGlobal("desktopBridge", bridge); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const render = () => act(async () => root.render(createElement(SnapShotCoordinator))); + return { + bridge, + route, + sound, + toast, + render, + setPending: (items: typeof pending) => { + pending = items; + }, + setEnabled: async (snapShotEnabled: boolean) => { + currentSettings = { ...currentSettings, snapShotEnabled }; + await render(); + }, + emit: (event: DesktopSnapShotEvent) => + act(async () => { + for (const listener of listeners) listener(event); + }), + }; + } + + afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + }); + + const focus = () => + act(async () => { + window.dispatchEvent(new Event("focus")); + }); + + it("leaves captures untouched while disabled and resumes them when enabled again", async () => { + const fixture = setup(); + const { bridge, sound, toast } = fixture; + await fixture.setEnabled(false); + await focus(); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(bridge.onSnapShotEvent).not.toHaveBeenCalled(); + expect(bridge.listPendingSnapShots).not.toHaveBeenCalled(); + expect(bridge.readSnapShot).not.toHaveBeenCalled(); + expect(sound).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); + + await fixture.setEnabled(true); + await vi.waitFor(() => + expect(bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), + ); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1); + expect(sound).toHaveBeenCalledOnce(); + + await fixture.setEnabled(false); + const another = { ...capture, id: "another-capture" }; + fixture.setPending([another]); + bridge.listPendingSnapShots.mockClear(); + await fixture.emit({ type: "started", id: another.id }); + await fixture.emit({ type: "ready", id: another.id }); + await fixture.emit({ type: "failed", id: another.id }); + await focus(); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(bridge.listPendingSnapShots).not.toHaveBeenCalled(); + expect(bridge.getSnapShotState).not.toHaveBeenCalled(); + expect(bridge.readSnapShot).toHaveBeenCalledOnce(); + expect(sound).toHaveBeenCalledOnce(); + expect(toast).not.toHaveBeenCalled(); + }); + + it("reports each pending capture failure once, allows a new failure kind, and resets after success", async () => { + const fixture = setup(null); + const { bridge, sound, toast, route } = fixture; + await fixture.render(); + await focus(); + expect(toast).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + title: "Snapshot taken, but no project is available", + }), + ); + expect(sound).toHaveBeenCalledOnce(); + expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); + + route.routeThreadRef = target; + bridge.readSnapShot.mockRejectedValue(new Error("Capture unavailable")); + await fixture.render(); + await focus(); + await fixture.emit({ type: "ready", id: capture.id }); + expect(toast.mock.calls.map(([value]) => value.title)).toEqual([ + "Snapshot taken, but no project is available", + "Snapshot failed", + ]); + expect(sound).toHaveBeenCalledOnce(); + expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); + + route.routeThreadRef = null; + await fixture.render(); + await focus(); + expect(toast).toHaveBeenCalledTimes(2); + route.routeThreadRef = target; + await fixture.render(); + + bridge.readSnapShot.mockResolvedValue(capture); + await focus(); + await vi.waitFor(() => + expect(bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), + ); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1); + fixture.setPending([capture]); + bridge.readSnapShot.mockRejectedValue(new Error("Capture unavailable again")); + await focus(); + await focus(); + expect(sound).toHaveBeenCalledTimes(2); + expect(toast.mock.calls.map(([value]) => value.title)).toEqual([ + "Snapshot taken, but no project is available", + "Snapshot failed", + "Snapshot failed", + ]); + }); + + it("suppresses deferred failures while disabled and reports fresh failures after re-enable", async () => { + const fixture = setup(); + const { bridge, sound, toast } = fixture; + let rejectRead: ((error: Error) => void) | undefined; + bridge.readSnapShot.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectRead = reject; + }), + ); + await fixture.render(); + expect(bridge.readSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); + await fixture.setEnabled(false); + await act(async () => rejectRead?.(new Error("Capture unavailable"))); + expect(toast).not.toHaveBeenCalled(); + expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); + + bridge.readSnapShot.mockRejectedValue(new Error("Capture unavailable")); + await fixture.setEnabled(true); + await focus(); + expect(toast).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ description: `Capture ${capture.id}: Capture unavailable` }), + ); + expect(sound).toHaveBeenCalledOnce(); + + await fixture.setEnabled(false); + let rejectList: ((error: Error) => void) | undefined; + bridge.listPendingSnapShots.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectList = reject; + }), + ); + await fixture.setEnabled(true); + await fixture.setEnabled(false); + await act(async () => rejectList?.(new Error("Pending captures unavailable"))); + expect(toast).toHaveBeenCalledOnce(); + + bridge.listPendingSnapShots.mockRejectedValueOnce(new Error("Pending captures unavailable")); + await fixture.setEnabled(true); + expect(toast).toHaveBeenCalledTimes(2); + expect(toast).toHaveBeenLastCalledWith( + expect.objectContaining({ description: "Pending captures unavailable" }), + ); + + fixture.setPending([]); + let resolveState: ((state: { message: string }) => void) | undefined; + bridge.getSnapShotState.mockReturnValueOnce( + new Promise<{ message: string }>((resolve) => { + resolveState = resolve; + }), + ); + await fixture.emit({ type: "failed", id: "native-capture" }); + expect(bridge.getSnapShotState).toHaveBeenCalledOnce(); + await fixture.setEnabled(false); + await act(async () => resolveState?.({ message: "Native capture unavailable" })); + expect(toast).toHaveBeenCalledTimes(2); + + bridge.getSnapShotState.mockResolvedValue({ message: "Native capture unavailable" }); + await fixture.setEnabled(true); + await fixture.emit({ type: "failed", id: "native-capture" }); + expect(toast).toHaveBeenCalledTimes(3); + expect(toast).toHaveBeenLastCalledWith( + expect.objectContaining({ description: "Native capture unavailable" }), + ); + expect(sound).toHaveBeenCalledOnce(); + expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); + }); + + it("keeps a capture pending when disabled during target resolution, then retries on re-enable", async () => { + const fixture = setup(null); + const { bridge, route } = fixture; + route.defaultProjectRef = projectRef; + const draftId = DraftId.make("resolving-snapshot"); + const threadId = ThreadId.make("resolving-snapshot-thread"); + let resolveTarget: ((target: { draftId: DraftId; threadId: ThreadId }) => void) | undefined; + const resolution = new Promise<{ draftId: DraftId; threadId: ThreadId }>((resolve) => { + resolveTarget = resolve; + }); + route.handleNewThread.mockReturnValue(resolution); + await fixture.render(); + expect(route.handleNewThread).toHaveBeenCalledOnce(); + await fixture.setEnabled(false); + await act(async () => { + useComposerDraftStore.getState().setProjectDraftThreadId(projectRef, draftId, { threadId }); + resolveTarget?.({ draftId, threadId }); + }); + expect(bridge.readSnapShot).not.toHaveBeenCalled(); + expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.images ?? []).toEqual([]); + await fixture.setEnabled(true); + await vi.waitFor(() => + expect(bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), + ); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.images).toHaveLength(1); + }); + + it("finishes and acknowledges delivery already reading when disabled", async () => { + const fixture = setup(); + let finishReading: ((value: typeof capture) => void) | undefined; + const reading = new Promise((resolve) => { + finishReading = resolve; + }); + fixture.bridge.readSnapShot.mockReturnValue(reading); + await fixture.render(); + expect(fixture.bridge.readSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); + await fixture.setEnabled(false); + await act(async () => { + finishReading?.(capture); + }); + await vi.waitFor(() => + expect(fixture.bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), + ); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1); + expect(fixture.toast).not.toHaveBeenCalled(); + }); +}); + describe("window capture delivery", () => { it.each([ { target: DraftId.make("snap-shot-draft"), accessibleText: undefined }, @@ -190,20 +501,14 @@ describe("window capture delivery", () => { ...(accessibleText ? { accessibleText } : {}), }, }; - vi.stubGlobal("window", { - localStorage: storage, - desktopBridge: bridge, - setTimeout, - clearTimeout, - matchMedia: () => ({ matches: false }), - getComputedStyle: () => ({ - backgroundColor: "rgb(0, 0, 0)", - borderTopColor: "rgb(255, 255, 255)", - borderTopLeftRadius: "8px", - borderTopWidth: "1px", - }), - dispatchEvent: vi.fn(), - }); + vi.stubGlobal("desktopBridge", bridge); + vi.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as MediaQueryList); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + backgroundColor: "rgb(0, 0, 0)", + borderTopColor: "rgb(255, 255, 255)", + borderTopLeftRadius: "8px", + borderTopWidth: "1px", + } as CSSStyleDeclaration); vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { animationFrames.push(callback); return animationFrames.length; @@ -224,7 +529,16 @@ describe("window capture delivery", () => { item.source, ); + const persisted = new Promise((resolve) => { + const unsubscribe = useComposerDraftStore.subscribe((state) => { + if (state.getComposerDraft(target)?.persistedAttachments.length) { + unsubscribe(); + resolve(); + } + }); + }); const delivery = deliverSnapShot(bridge, item, target); + await persisted; await vi.advanceTimersByTimeAsync(0); const draft = useComposerDraftStore.getState().getComposerDraft(target); @@ -314,7 +628,6 @@ describe("durable snapshot delivery", () => { readSnapShot: async () => capture, acknowledgeSnapShot, } as unknown as DesktopSnapShotBridge; - vi.stubGlobal("window", { localStorage: storage, dispatchEvent: vi.fn() }); const write = storage.setItem.getMockImplementation()!; storage.setItem.mockImplementation(() => { throw new Error("QuotaExceededError"); diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index 429667b09e84..1765536ab797 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -13,7 +13,7 @@ import { } from "../../composerDraftStore"; import { useHandleNewThread } from "../../hooks/useHandleNewThread"; import { useStableCallback } from "../../hooks/useStableCallback"; -import { useClientSettings } from "../../hooks/useSettings"; +import { getClientSettings, useClientSettings } from "../../hooks/useSettings"; import { readThreadShell } from "../../state/entities"; import { compressImageToByteLimit, dataUrlToFile } from "../../lib/imageCompression"; import { resolveThreadActionProjectRef } from "../../lib/chatThreadActions"; @@ -37,6 +37,7 @@ import { readFileAsDataUrl } from "../ChatView.logic"; import { stackedThreadToast, toastManager } from "../ui/toast"; type CaptureTarget = DraftId | ScopedThreadRef; +type CaptureFailure = "missing-project" | "delivery"; function isSameCaptureTarget(a: CaptureTarget, b: CaptureTarget): boolean { if (typeof a === "string" || typeof b === "string") return a === b; @@ -213,11 +214,13 @@ export function SnapShotCoordinator() { settings.snapShotPlaySound ? settings.snapShotSound : null, ); const animateCaptures = useClientSettings((settings) => settings.snapShotAnimations); + const enabled = useClientSettings((settings) => settings.snapShotEnabled); const captureTargetsRef = useRef(new Map>()); const targetResolutionRef = useRef | null>(null); const drainingRef = useRef | null>(null); const rerunRequestedRef = useRef(false); const soundedCaptureIdsRef = useRef(new Set()); + const reportedCaptureFailuresRef = useRef(new Map>()); const pendingAnimationStartsRef = useRef(new Set()); // Captures go to the last thread or draft on screen, even from routes that show neither. @@ -280,7 +283,21 @@ export function SnapShotCoordinator() { } catch {} }; + const reportOnce = ( + id: string, + failure: CaptureFailure, + toast: Parameters[0], + ) => { + if (!getClientSettings().snapShotEnabled) return; + const failures = reportedCaptureFailuresRef.current.get(id) ?? new Set(); + if (failures.has(failure)) return; + failures.add(failure); + reportedCaptureFailuresRef.current.set(id, failures); + toastManager.add(toast); + }; + const drain = async () => { + if (!getClientSettings().snapShotEnabled) return; const bridge = getDesktopSnapShotBridge(); if (!bridge) return; if (drainingRef.current) { @@ -293,19 +310,22 @@ export function SnapShotCoordinator() { rerunRequestedRef.current = false; const pending = await bridge.listPendingSnapShots(); for (const item of pending) { + if (!getClientSettings().snapShotEnabled) return; playCaptureSound(item.id); const capturedTarget = await resolveSnapShotDeliveryTarget( captureTargetsRef.current, item.id, resolveCaptureTarget, ); + if (!getClientSettings().snapShotEnabled) return; const target = capturedTarget ? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef) : null; if (!target) { await dismissSnapShotAnimation(item.id); - soundedCaptureIdsRef.current.delete(item.id); - toastManager.add( + reportOnce( + item.id, + "missing-project", stackedThreadToast({ type: "error", title: "Snapshot taken, but no project is available", @@ -319,10 +339,12 @@ export function SnapShotCoordinator() { await deliverSnapShot(bridge, item, target); captureTargetsRef.current.delete(item.id); soundedCaptureIdsRef.current.delete(item.id); + reportedCaptureFailuresRef.current.delete(item.id); } catch (error) { await dismissSnapShotAnimation(item.id); - soundedCaptureIdsRef.current.delete(item.id); - toastManager.add( + reportOnce( + item.id, + "delivery", stackedThreadToast({ type: "error", title: "Snapshot failed", @@ -333,10 +355,11 @@ export function SnapShotCoordinator() { ); } } - } while (rerunRequestedRef.current); + } while (rerunRequestedRef.current && getClientSettings().snapShotEnabled); })() .catch((error: unknown) => { dismissAllSnapShotAnimations(); + if (!getClientSettings().snapShotEnabled) return; toastManager.add( stackedThreadToast({ type: "error", @@ -357,6 +380,7 @@ export function SnapShotCoordinator() { const drainPending = useEffectEvent(() => drain()); const handleSnapShotEvent = useEffectEvent( (bridge: DesktopSnapShotBridge, event: DesktopSnapShotEvent) => { + if (!getClientSettings().snapShotEnabled) return; switch (event.type) { case "requested": { const current = readSettledTarget(readLastRouteTarget()); @@ -395,6 +419,7 @@ export function SnapShotCoordinator() { pendingAnimationStartsRef.current, ); void bridge.getSnapShotState().then((state) => { + if (!getClientSettings().snapShotEnabled) return; toastManager.add( stackedThreadToast({ type: "error", @@ -412,10 +437,10 @@ export function SnapShotCoordinator() { ); useEffect(() => { const bridge = getDesktopSnapShotBridge(); - if (!bridge) return; + if (!bridge || !enabled) return; void drainPending(); return bridge.onSnapShotEvent((event) => handleSnapShotEvent(bridge, event)); - }, []); + }, [enabled]); useEffect(() => { const dismissOnBlur = () => { From 62880642c08421389e9e2c5b6724775605c53dac Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 02:49:10 +0000 Subject: [PATCH 052/458] fix(clients): keep a checkout terminal off the thread's worktree (#16039) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- .../terminal/ThreadTerminalRouteScreen.tsx | 76 ++++------- .../terminal/terminalLaunchContext.test.ts | 128 +++++++++++++++++- .../terminal/terminalLaunchContext.ts | 56 ++++++-- .../web/src/components/ChatView.logic.test.ts | 27 ++++ apps/web/src/components/ChatView.logic.ts | 11 ++ .../components/PersistentThreadTerminals.tsx | 15 +- .../client-runtime/src/state/runtime.test.ts | 79 +++++++++++ packages/client-runtime/src/state/terminal.ts | 2 + 8 files changed, 328 insertions(+), 66 deletions(-) diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 239fbeda7d39..c43a50c722b5 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -70,7 +70,7 @@ import { TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, } from "./terminalBufferReplay"; import { - resolveTerminalOpenLocation, + useInitialTerminalLaunchLocation, takePendingTerminalLaunch, type PendingTerminalLaunch, } from "./terminalLaunchContext"; @@ -396,36 +396,25 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) requestedTerminalId === null && runningSession !== null && runningSession.target.terminalId !== terminalId; - const launchLocationCandidate = useMemo(() => { - if (!selectedThread || !selectedThreadProject?.workspaceRoot) { - return null; - } - if (pendingLaunch) { - return { - cwd: pendingLaunch.cwd, - worktreePath: pendingLaunch.worktreePath, - }; - } - return resolveTerminalOpenLocation({ - terminalLocation: activeKnownSession?.state.summary ?? null, - activeSessionLocation: activeKnownSession?.state.summary ?? null, - workspaceRoot: selectedThreadProject.workspaceRoot, - threadShellWorktreePath: selectedThread.worktreePath ?? null, - threadDetailWorktreePath: selectedThreadDetailWorktreePath, - }); - }, [ - activeKnownSession?.state.summary, + const launchLocation = useInitialTerminalLaunchLocation({ + targetKey: launchTargetKey, pendingLaunch, - selectedThread, - selectedThreadDetailWorktreePath, - selectedThreadProject?.workspaceRoot, - ]); - const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({ - key: launchTargetKey, - location: launchLocationCandidate, - })); - const launchLocation = - initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null; + hasResolvedPendingLaunch, + isAuthenticated, + canRead: canReadTerminal, + canOperate: canOperateTerminal, + hasLoadedSessions: knownSessions !== null, + location: + selectedThread && selectedThreadProject?.workspaceRoot + ? { + terminalLocation: activeKnownSession?.state.summary ?? null, + activeSessionLocation: activeKnownSession?.state.summary ?? null, + workspaceRoot: selectedThreadProject.workspaceRoot, + threadShellWorktreePath: selectedThread.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetailWorktreePath, + } + : null, + }); const terminalAttachInput = useMemo( () => selectedThread !== null && @@ -670,27 +659,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]); - useEffect(() => { - if ( - initialLaunchLocationEntry.key === launchTargetKey && - initialLaunchLocationEntry.location !== null - ) { - return; - } - if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) { - return; - } - setInitialLaunchLocationEntry({ - key: launchTargetKey, - location: launchLocationCandidate, - }); - }, [ - initialLaunchLocationEntry.key, - initialLaunchLocationEntry.location, - launchLocationCandidate, - launchTargetKey, - ]); - useEffect(() => { if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { return; @@ -1243,6 +1211,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) "This connection does not have permission to view terminals." } /> + ) : canOperateTerminal && launchLocation === null ? ( + sessionsError !== null ? ( + + ) : ( + + ) ) : !canOperateTerminal && !hasTerminalTarget && sessionsPending ? ( ) : !canOperateTerminal && !hasTerminalTarget && sessionsError !== null ? ( diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts index 470acffa3d43..f68dac9cefde 100644 --- a/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeModule from "node:module"; +import { act, createElement, useEffect, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { @@ -6,6 +8,7 @@ import { resolveTerminalOpenLocation, stagePendingTerminalLaunch, takePendingTerminalLaunch, + useInitialTerminalLaunchLocation, } from "./terminalLaunchContext"; describe("resolvePreferredThreadWorktreePath", () => { @@ -61,6 +64,34 @@ describe("resolveTerminalOpenLocation", () => { worktreePath: "/repo/worktrees/feature", }); }); + + it("preserves checkout cwd and identity from the selected terminal or active session", () => { + const checkout = { cwd: "/repo/root/packages/app", worktreePath: null }; + const threadLocation = { + workspaceRoot: "/repo/root", + threadShellWorktreePath: "/repo/worktrees/feature", + threadDetailWorktreePath: "/repo/worktrees/feature", + }; + + expect( + resolveTerminalOpenLocation({ + ...threadLocation, + terminalLocation: checkout, + activeSessionLocation: { + cwd: "/repo/worktrees/other", + worktreePath: "/repo/worktrees/other", + }, + }), + ).toEqual(checkout); + + expect( + resolveTerminalOpenLocation({ + ...threadLocation, + terminalLocation: null, + activeSessionLocation: checkout, + }), + ).toEqual(checkout); + }); }); describe("pending terminal launches", () => { @@ -120,3 +151,98 @@ describe("pending terminal launches", () => { }); }); }); + +describe("initial terminal launch location", () => { + it("waits for session metadata before retaining a location for each route target", async () => { + const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as { + createRoot(container: Element): { + render(children: ReactNode): void; + unmount(): void; + }; + }; + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const root = createRoot(container as unknown as HTMLElement); + type Input = Parameters[0]; + let location: ReturnType = null; + function Probe(input: Input) { + const selectedLocation = useInitialTerminalLaunchLocation(input); + useEffect(() => { + location = selectedLocation; + }, [selectedLocation]); + return null; + } + const threadLocation = { + terminalLocation: null, + activeSessionLocation: null, + workspaceRoot: "/repo/root", + threadShellWorktreePath: "/repo/worktrees/feature", + threadDetailWorktreePath: "/repo/worktrees/feature", + }; + const checkout = { cwd: "/repo/root/packages/app", worktreePath: null }; + const input: Input = { + targetKey: "environment:thread:term-1", + pendingLaunch: null, + hasResolvedPendingLaunch: true, + isAuthenticated: true, + canRead: true, + canOperate: true, + hasLoadedSessions: false, + location: threadLocation, + }; + const render = (changes: Partial = {}) => + act(() => root.render(createElement(Probe, { ...input, ...changes }))); + try { + // Thread/project are ready before authentication and the metadata snapshot. + await render({ isAuthenticated: false, canRead: false, canOperate: false }); + expect(location).toBeNull(); + await render(); + expect(location).toBeNull(); + await render({ + hasLoadedSessions: true, + canOperate: false, + location: { ...threadLocation, terminalLocation: checkout }, + }); + expect(location).toEqual(checkout); + + // Reconnects and subsequent thread changes must not move the selected terminal. + await render({ location: { ...threadLocation, threadDetailWorktreePath: "/repo/other" } }); + expect(location).toEqual(checkout); + + const nextTarget = { targetKey: "environment:thread:term-2" }; + await render(nextTarget); + expect(location).toBeNull(); + // A loaded empty session list permits the fresh-open thread fallback. + await render({ ...nextTarget, hasLoadedSessions: true }); + expect(location).toEqual({ + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + }); + + await render({ targetKey: "environment:thread:term-3", hasResolvedPendingLaunch: false }); + expect(location).toBeNull(); + await render({ targetKey: "environment:thread:term-3", pendingLaunch: checkout }); + expect(location).toEqual(checkout); + + // Operate-only grants cannot load metadata, so they still use the thread default. + await render({ targetKey: "environment:thread:term-4", canRead: false }); + expect(location).toEqual({ + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + }); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.ts index af67497a3d22..519660036463 100644 --- a/apps/mobile/src/features/terminal/terminalLaunchContext.ts +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; interface TerminalLocationLike { readonly cwd: string; @@ -65,20 +66,55 @@ export function resolveTerminalOpenLocation(input: { readonly cwd: string; readonly worktreePath: string | null; } { + const sessionLocation = input.terminalLocation ?? input.activeSessionLocation; + + // Keep cwd paired with its identity; null identifies the project checkout. + if (sessionLocation !== null) { + return { cwd: sessionLocation.cwd, worktreePath: sessionLocation.worktreePath }; + } const preferredThreadWorktreePath = resolvePreferredThreadWorktreePath({ threadShellWorktreePath: input.threadShellWorktreePath, threadDetailWorktreePath: input.threadDetailWorktreePath, }); - return { - cwd: - input.terminalLocation?.cwd ?? - input.activeSessionLocation?.cwd ?? - preferredThreadWorktreePath ?? - input.workspaceRoot, - worktreePath: - input.terminalLocation?.worktreePath ?? - input.activeSessionLocation?.worktreePath ?? - preferredThreadWorktreePath, + cwd: preferredThreadWorktreePath ?? input.workspaceRoot, + worktreePath: preferredThreadWorktreePath, }; } + +export function useInitialTerminalLaunchLocation(input: { + readonly targetKey: string | null; + readonly pendingLaunch: PendingTerminalLaunch | null; + readonly hasResolvedPendingLaunch: boolean; + readonly isAuthenticated: boolean; + readonly canRead: boolean; + readonly canOperate: boolean; + readonly hasLoadedSessions: boolean; + readonly location: Parameters[0] | null; +}) { + let candidate: TerminalLocationLike | null = null; + if ( + input.location !== null && + input.hasResolvedPendingLaunch && + input.isAuthenticated && + (input.canRead || input.canOperate) + ) { + if (input.pendingLaunch !== null) { + candidate = { + cwd: input.pendingLaunch.cwd, + worktreePath: input.pendingLaunch.worktreePath, + }; + } else if (!input.canRead || input.hasLoadedSessions) { + // Metadata must establish whether this target has a location before we retain a fallback. + candidate = resolveTerminalOpenLocation(input.location); + } + } + const [entry, setEntry] = useState(() => ({ key: input.targetKey, location: candidate })); + useEffect(() => { + if (entry.key === input.targetKey && (entry.location !== null || candidate === null)) { + return; + } + setEntry({ key: input.targetKey, location: candidate }); + }, [candidate, entry.key, entry.location, input.targetKey]); + return entry.key === input.targetKey ? entry.location : null; +} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0ce4d137414e..8a38f9a102aa 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -89,6 +89,7 @@ import { hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, + resolveTerminalWorktreePath, resolveDraftPromotionNavigationTarget, resolveEffectiveInteractionMode, resolveThreadMetadataUpdateForNextTurn, @@ -520,6 +521,32 @@ describe("session branch mismatch dismissal", () => { }); }); +describe("resolveTerminalWorktreePath", () => { + it("uses the terminal's checkout identity until neither launch nor session is known", () => { + const threadWorktreePath = "/repo/.worktrees/feature"; + + expect( + resolveTerminalWorktreePath({ + launchContext: { worktreePath: null }, + summary: { worktreePath: "/repo/.worktrees/other" }, + threadWorktreePath, + }), + ).toBeNull(); + + expect( + resolveTerminalWorktreePath({ + launchContext: null, + summary: { worktreePath: null }, + threadWorktreePath, + }), + ).toBeNull(); + + expect( + resolveTerminalWorktreePath({ launchContext: null, summary: null, threadWorktreePath }), + ).toBe(threadWorktreePath); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps open threads and makes the active thread most recent", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 7479a289d8ff..3e0f7a13e487 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -681,6 +681,17 @@ export function reconcileMountedTerminalThreadIds(input: { return nextThreadIds; } +// Null identifies the project checkout when a launch or session is known. +export function resolveTerminalWorktreePath(input: { + launchContext: { readonly worktreePath: string | null } | null; + summary: { readonly worktreePath: string | null } | null; + threadWorktreePath: string | null; +}): string | null { + if (input.launchContext !== null) return input.launchContext.worktreePath; + if (input.summary !== null) return input.summary.worktreePath; + return input.threadWorktreePath; +} + export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 44b40f83e753..f7658ba41933 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -24,6 +24,7 @@ import { useKnownTerminalSessions } from "../state/terminalSessions"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import { resolveTerminalWorktreePath } from "./ChatView.logic"; /** Where a drawer or panel opens new terminals when a script run overrides the thread's own. */ export type PersistentTerminalLaunchContext = { @@ -346,8 +347,11 @@ export const PersistentThreadTerminalPanel = memo(function PersistentThreadTermi const activeSummary = knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; - const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; + const worktreePath = resolveTerminalWorktreePath({ + launchContext, + summary: activeSummary, + threadWorktreePath, + }); const cwd = launchContext?.cwd ?? activeSummary?.cwd ?? @@ -363,8 +367,11 @@ export const PersistentThreadTerminalPanel = memo(function PersistentThreadTermi .summary ?? null; terminalLabelsById.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); if (!project) continue; - const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalWorktreePath = resolveTerminalWorktreePath({ + launchContext, + summary, + threadWorktreePath, + }); terminalLaunchLocationsById.set(terminalId, { cwd: launchContext?.cwd ?? diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index b1cc4f83506d..e31092acc756 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -3,6 +3,8 @@ import { EnvironmentId, type TerminalAttachInput, type TerminalAttachStreamEvent, + type TerminalMetadataStreamEvent, + type TerminalSummary, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -388,6 +390,83 @@ describe("finite environment subscription lifecycle", () => { }); describe("environment query lifecycle", () => { + it.effect( + "terminal metadata waits for snapshots and retains known sessions across restarts", + () => + Effect.gen(function* () { + const harness = yield* makeEnvironmentQueryHarness(Effect.void); + const started = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); + const receivedCheckout = yield* Deferred.make(); + const receivedEmpty = yield* Deferred.make(); + const checkout: TerminalSummary = { + threadId: "thread-1", + terminalId: "terminal-1", + cwd: "/repo/root/packages/app", + worktreePath: null, + status: "running", + pid: 123, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal 1", + updatedAt: "2026-04-01T00:00:00.000Z", + }; + const client = { + [WS_METHODS.subscribeTerminalMetadata]: () => + Stream.fromEffect(Queue.offer(started, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(events)), + ), + } as unknown as RpcSession.RpcSession["client"]; + const session = { ...QUERY_RPC_SESSION, client }; + yield* SubscriptionRef.set(harness.supervisorSession, Option.some(session)); + const atoms = createTerminalEnvironmentAtoms(harness.runtime); + const atom = atoms.metadata({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: null, + }); + const registry = AtomRegistry.make(); + const observed: Array> = []; + const unsubscribe = registry.subscribe( + atom, + (result) => { + if (!AsyncResult.isSuccess(result)) return; + observed.push(result.value); + if (result.value.length > 0) Deferred.doneUnsafe(receivedCheckout, Exit.void); + else Deferred.doneUnsafe(receivedEmpty, Exit.void); + }, + { immediate: true }, + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unsubscribe(); + registry.dispose(); + }), + ); + + yield* Queue.take(started); + expect(selectEnvironmentQueryData(registry.get(atom))).toBeNull(); + expect(observed).toEqual([]); + + yield* Queue.offer(events, { type: "snapshot", terminals: [checkout] }); + yield* Deferred.await(receivedCheckout); + expect(selectEnvironmentQueryData(registry.get(atom))).toEqual([checkout]); + + yield* SubscriptionRef.set(harness.supervisorSession, Option.some({ ...session })); + yield* Queue.take(started); + expect(selectEnvironmentQueryData(registry.get(atom))).toEqual([checkout]); + + yield* SubscriptionRef.update(harness.streamGeneration, (generation) => generation + 1); + yield* Queue.take(started); + expect(selectEnvironmentQueryData(registry.get(atom))).toEqual([checkout]); + + yield* Queue.offer(events, { type: "snapshot", terminals: [] }); + yield* Deferred.await(receivedEmpty); + expect(selectEnvironmentQueryData(registry.get(atom))).toEqual([]); + }), + ); + it.effect("terminal attach resubscribes with resume only after the stream replied", () => Effect.gen(function* () { const harness = yield* makeEnvironmentQueryHarness(Effect.void); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 59da22c18591..8c0a1113e151 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -73,6 +73,8 @@ export function createTerminalEnvironmentAtoms( subscribe: (_input: null) => subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( Stream.scan((): ReadonlyArray => [], applyTerminalMetadataStreamEvent), + // The scan seed is not an authoritative empty server snapshot. + Stream.drop(1), ), }), open: createEnvironmentRpcCommand(runtime, { From 58b48274ff7db9361ab904d90c76b968e9a9228f Mon Sep 17 00:00:00 2001 From: Hong Liu <88263959+Hylouis233@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:49:16 +0800 Subject: [PATCH 053/458] fix(server): classify Codex 429 retry exhaustion as a usage limit (#17501) Co-authored-by: Hylouis233 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../Adapters/CodexAdapterV2.test.ts | 129 +++++++++++++++++- .../Adapters/CodexAdapterV2.ts | 78 +++++++---- 2 files changed, 180 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 17d43f5c14a2..c1622f04869a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -6852,12 +6852,27 @@ describe("CodexAdapterV2 post-settle continuation", () => { expectedClass: "usage_limit", }, { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, + { + name: "exhausted-429", + code: "responseTooManyFailedAttempts", + errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 429 } }, + notification: false, + expectedClass: "usage_limit", + }, + { + name: "exhausted-500", + code: "responseTooManyFailedAttempts", + errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 500 } }, + notification: false, + expectedClass: "provider_error", + }, ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => Effect.scoped( Effect.gen(function* () { const nativeThreadId = `native-limit-${scenario.name}`; const nativeTurnId = `turn-limit-${scenario.name}`; const message = "Provider stopped this request."; + const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code; const resetAt = "2033-05-19T07:20:00.000Z"; const snapshot = { type: "emit_inbound" as const, @@ -6928,7 +6943,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { willRetry: scenario.name === "retry", error: { message, - codexErrorInfo: scenario.code, + codexErrorInfo: errorInfo, additionalDetails: scenario.name === "matching-details" ? "Detailed provider allowance explanation." @@ -6952,7 +6967,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { message: scenario.name === "replacement" ? "A different failure." : message, ...(scenario.notification && scenario.name !== "matching-details" ? {} - : { codexErrorInfo: scenario.code }), + : { codexErrorInfo: errorInfo }), }, }, }, @@ -7041,6 +7056,116 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each([ + { + notificationStatus: 500, + completionStatus: 429, + willRetry: false, + expectedClass: "usage_limit", + }, + { + notificationStatus: 429, + completionStatus: 500, + willRetry: true, + expectedClass: "provider_error", + }, + { + notificationStatus: 429, + completionStatus: null, + willRetry: false, + expectedClass: "usage_limit", + }, + { + notificationStatus: 429, + completionStatus: null, + willRetry: true, + expectedClass: "usage_limit", + }, + ] as const)( + "honors completion HTTP status from $notificationStatus to $completionStatus (retry: $willRetry)", + ({ notificationStatus, completionStatus, expectedClass, willRetry }) => + Effect.scoped( + Effect.gen(function* () { + const name = `${notificationStatus}-${completionStatus}-${willRetry}`; + const nativeThreadId = `native-exhaustion-status-${name}`; + const nativeTurnId = `turn-exhaustion-status-${name}`; + const message = "Provider stopped this request."; + const additionalDetails = "Detailed notification evidence."; + const transcript = makeCodexReplayTranscript({ + scenario: `codex-exhaustion-status-${name}`, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + { + type: "emit_inbound", + label: "error", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry, + error: { + message, + codexErrorInfo: { + responseTooManyFailedAttempts: { httpStatusCode: notificationStatus }, + }, + additionalDetails, + }, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), + error: { + message, + codexErrorInfo: { + responseTooManyFailedAttempts: { httpStatusCode: completionStatus }, + }, + }, + }, + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + text: "Continue.", + attemptId: RunAttemptId.make(`attempt-exhaustion-status-${name}`), + }), + ); + yield* harness.firstTerminal; + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.failure.class, expectedClass); + assert.equal(terminal.failure.code, "responseTooManyFailedAttempts"); + assert.equal(terminal.threadDisposition, "reusable"); + const notificationClass = notificationStatus === 429 ? "usage_limit" : "provider_error"; + assert.equal( + terminal.failure.message, + expectedClass === notificationClass ? additionalDetails : message, + ); + if (willRetry) assert.equal(terminal.retry?.attempt, 1); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + const FAILED_SCENARIO = "codex-failed-mid-command"; const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; const FAILED_NATIVE_TURN = "native-codex-failed-turn"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4750aad53269..a971e77fced4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1110,6 +1110,32 @@ function codexErrorInfoCode(value: unknown): string | null { return Object.keys(value)[0] ?? null; } +type CodexErrorInfo = CodexSchema.ServerNotification__CodexErrorInfo; + +/** + * Retry exhaustion only says why it failed through the last attempt's HTTP + * status. Exhaustion on 429 is a usage or rate limit, so it keeps the Limited + * state and resume controls; other statuses stay provider or transport errors. + */ +function codexExhaustionHttpStatus(errorInfo: CodexErrorInfo | null | undefined): number | null { + return typeof errorInfo === "object" && + errorInfo !== null && + "responseTooManyFailedAttempts" in errorInfo + ? (errorInfo.responseTooManyFailedAttempts.httpStatusCode ?? null) + : null; +} + +function codexUsageLimitError( + code: string | null, + errorInfo: CodexErrorInfo | null | undefined, +): boolean { + return ( + code === "usageLimitExceeded" || + code === "rateLimitExceeded" || + codexExhaustionHttpStatus(errorInfo) === 429 + ); +} + interface ActiveCodexTurnContext { latestProviderFailure?: { readonly nativeMessage: string; @@ -4532,11 +4558,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( failure: makeProviderFailure({ message: payload.error.additionalDetails?.trim() || payload.error.message, code: notificationCode, - class: - notificationCode === "usageLimitExceeded" || - notificationCode === "rateLimitExceeded" - ? "usage_limit" - : "provider_error", + class: codexUsageLimitError(notificationCode, payload.error.codexErrorInfo) + ? "usage_limit" + : "provider_error", }), }; return; @@ -4557,12 +4581,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? payload.error.message : additionalDetails, code, - class: - code === "usageLimitExceeded" || code === "rateLimitExceeded" - ? "usage_limit" - : code?.startsWith("http") === true || code?.startsWith("responseStream") === true - ? "transport_error" - : "provider_error", + class: codexUsageLimitError(code, payload.error.codexErrorInfo) + ? "usage_limit" + : code?.startsWith("http") === true || code?.startsWith("responseStream") === true + ? "transport_error" + : "provider_error", retryable: true, }); const itemOrdinal = @@ -5560,27 +5583,34 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly context: ActiveCodexTurnContext; readonly status: OrchestrationV2ProviderTurn["status"]; readonly failureMessage?: string; - readonly failureCode?: string | null; + readonly failureErrorInfo?: CodexErrorInfo; readonly providerRetry?: ActiveCodexProviderRetry; }): Effect.fn.Return { const terminalStatus = providerTurnStatusToTerminal(input.status); if (terminalStatus === "failed") { const previousFailure = input.context.latestProviderFailure ?? input.providerRetry; + const failureCode = + input.failureErrorInfo === undefined + ? undefined + : codexErrorInfoCode(input.failureErrorInfo); + const failureClass = codexUsageLimitError(failureCode ?? null, input.failureErrorInfo) + ? "usage_limit" + : "provider_error"; + // The notification carries the richer message, so it wins when the + // completion repeats it. An explicit exhaustion status on the + // completion still decides the class; a missing one is not evidence. + const exhaustionStatus = codexExhaustionHttpStatus(input.failureErrorInfo); const failure = previousFailure !== undefined && (input.failureMessage === undefined || input.failureMessage === previousFailure.nativeMessage) && - (input.failureCode === undefined || - input.failureCode === previousFailure.failure.code) + (failureCode === undefined || failureCode === previousFailure.failure.code) && + (exhaustionStatus === null || previousFailure.failure.class === failureClass) ? previousFailure.failure : makeProviderFailure({ message: input.failureMessage, - code: input.failureCode, - class: - input.failureCode === "usageLimitExceeded" || - input.failureCode === "rateLimitExceeded" - ? "usage_limit" - : "provider_error", + code: failureCode, + class: failureClass, }); return { type: "turn.terminal", @@ -5672,7 +5702,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly nativeTurnId: string; readonly status: OrchestrationV2ProviderTurn["status"]; readonly failureMessage?: string; - readonly failureCode?: string | null; + readonly failureErrorInfo?: CodexErrorInfo; readonly providerRetry?: ActiveCodexProviderRetry; readonly goalHoldTurn?: OrchestrationV2ProviderTurn; }) { @@ -5782,7 +5812,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly status: OrchestrationV2ProviderTurn["status"]; readonly completedAt: DateTime.Utc; readonly failureMessage?: string; - readonly failureCode?: string | null; + readonly failureErrorInfo?: CodexErrorInfo; }) => turnTerminalizationPermit.withPermits(1)( Effect.gen(function* () { @@ -6055,9 +6085,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( failureMessage: payload.turn.error.message, ...(payload.turn.error.codexErrorInfo == null ? {} - : { - failureCode: codexErrorInfoCode(payload.turn.error.codexErrorInfo), - }), + : { failureErrorInfo: payload.turn.error.codexErrorInfo }), }), }); }), From 098eb55cc377fe8a7f3b990bdf960f9c87e54d91 Mon Sep 17 00:00:00 2001 From: IlyaL Date: Mon, 19 Oct 2026 19:49:29 -0700 Subject: [PATCH 054/458] fix(web): ask before Close with comment closes a pull request (#18175) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../pullRequest/PullRequestCommentForm.tsx | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx index de1114fa985a..5cefd1faacbd 100644 --- a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx @@ -9,6 +9,8 @@ import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; import { toastManager } from "../ui/toast"; import { PullRequestGlyph } from "./pullRequestIcons"; +import { pullRequestReviewKey } from "./pullRequestReviewStore"; +import { useInlineActionConfirm } from "./useInlineActionConfirm"; export function PullRequestCommentForm({ environmentId, @@ -37,6 +39,7 @@ export function PullRequestCommentForm({ const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false, }); + const inlineConfirm = useInlineActionConfirm(pullRequestReviewKey(reference)); const followUpAction = detail.state === "open" && detail.capabilities.actions.includes("close") && @@ -111,20 +114,34 @@ export function PullRequestCommentForm({ size="xs" variant={followUpAction === "close" ? "destructive-outline" : "outline"} disabled={body.trim().length === 0 || submitting !== null || actionPending} - onClick={() => void submit(followUpAction)} + {...(followUpAction === "close" + ? inlineConfirm.props("close", () => void submit("close")) + : { onClick: () => void submit("reopen") })} > {followUpAction === "close" ? ( ) : ( )} - {submitting === followUpAction - ? followUpAction === "close" - ? "Closing..." - : "Reopening..." - : followUpAction === "close" - ? "Close with comment" - : "Reopen with comment"} + {followUpAction === "close" ? ( + // The longest label holds the width, so the confirming click lands on the button. + + + Close with comment + + + {submitting === "close" + ? "Closing..." + : inlineConfirm.isArmed("close") + ? "Confirm close?" + : "Close with comment"} + + + ) : submitting === "reopen" ? ( + "Reopening..." + ) : ( + "Reopen with comment" + )} )}